[GH-ISSUE #2195] [Bug] MCP stdio: capture_browser_scenario fails permanently with "Browser scenario provider is closing." after a discarded server/discover probe #734

Closed
opened 2026-10-15 18:27:48 +00:00 by bsakaguchi · 0 comments
Owner

Originally created by @JackOdegard on GitHub (Oct 12, 2026).
Original GitHub issue: https://github.com/morluto/rea/issues/2195

Before submitting

  • I searched the open and closed issues for a related report.
  • I removed credentials, tokens, private paths, and sensitive target data.

Area

MCP or agent integration

Observed behavior

Summary

Over MCP stdio, the outcome depends on how the connection opens:

  • If it opens with a server/discover probe and then falls back to a legacy initialize, every capture_browser_scenario call on that server process fails with code: "cleanup_incomplete" and the message Browser scenario provider is closing.
  • If it opens with a legacy initialize only, or keeps the probe instance through the 2026-07-28 protocol, the same scenario passes.
  • The CLI also passes.

The source shows why. The process-wide browser scenario provider is closed when the SDK discards the probe server instance.

Expected behavior

The opening sequence of an MCP connection should not change tool availability. Discarding the discovery-probe instance should release only resources owned by that instance. capture_browser_scenario should then run the scenario and return Evidence, as it does after a legacy-only or modern opening and in the CLI.

Actual behavior

Every call fails before any browser work, with this tool error:

{"error":{"code":"cleanup_incomplete","category":"execution_failure","message":"Browser scenario provider is closing.","retryable":false,"remediation":{"action":"Browser scenario provider is closing."},"details":{"operation":"capture_browser_scenario","reason":"cleanup_failed","cleanup":"incomplete","resources":[],"cleanup_reason":"Provider is closing","execution_failure":"unreadable_output"}}}
  • The call returns in 1–16 ms across all runs of the script below. In Claude Code the client measured 75 ms and 22 ms. No browser process is started.
  • The failure persists within the process. In the script, the second call on the same process fails the same way. In Claude Code, two calls 3 min 50 s apart failed: one in launch mode and one in connect mode against an already running loopback CDP target.
  • Other tools keep working in the affected Claude Code server process: list_browser_targets, inspect_web_page, analyze_web_bundle, inspect_web_event_listeners, capture_web_screenshot, and the Ghidra native-analysis tools.

How each error field arises in 6.4.0 (source-derived; the values are observed):

Field Value Origin
details.reason cleanup_failed Internal BrowserObservationError.reason, chosen by PlaywrightBrowserScenarioProvider#closedError() once the provider is closing.
code cleanup_incomplete projectAnalysisError() replaces the underlying code with cleanup_incomplete whenever the error sets cleanupIncomplete. BrowserObservationError sets that flag for reason cleanup_failed or when a cleanup record is attached.
details.cleanup, details.resources, details.cleanup_reason incomplete, [], Provider is closing Projected because cleanupIncomplete is set, from the cleanup record that #closedError() attaches (reason: "Provider is closing", resources: []).
details.execution_failure unreadable_output The underlying code. browserErrorCode() falls back to unreadable_output for browser reasons it does not map, including cleanup_failed.
category execution_failure Derived by analysisErrorCategory() from the browser reason.
retryable false cleanup_incomplete is not in the retryable code set.
message, remediation.action Browser scenario provider is closing. The provider's detail string. The observed remediation repeats the message.

Reproduction

Reproduction

You need Node.js 22 or later, an installed rea-agents package and a local Chrome or Chromium executable. The script below has no dependencies. It serves its own fictional page on 127.0.0.1. For each MCP mode it starts a fresh rea mcp process, opens it with one sequence, and calls capture_browser_scenario twice. The cli mode runs the same scenario through rea capture-browser-scenario.

  1. Save the script below as repro-probe-discard.mjs.

  2. Run it with the REA entry point and an absolute browser path. The command works the same in a POSIX shell and in PowerShell:

    node repro-probe-discard.mjs "$(npm root -g)/rea-agents/scripts/rea.mjs" "<absolute-path-to-chrome-or-chromium>"
    
  3. Read the one-line JSON result per mode. Output on the system above:

{"mode":"legacy-only","initialize":"2025-11-25","calls":[{"ms":1831,"outcome":"pass"},{"ms":1871,"outcome":"pass"}],"server_exit":0}
{"mode":"probe-then-legacy","discover":"answered","initialize":"2025-11-25","calls":[{"ms":12,"outcome":"tool-error","code":"cleanup_incomplete","reason":"cleanup_failed","message":"Browser scenario provider is closing."},{"ms":2,"outcome":"tool-error","code":"cleanup_incomplete","reason":"cleanup_failed","message":"Browser scenario provider is closing."}],"server_exit":0}
{"mode":"probe-unanswered-then-legacy","discover":"sent, not awaited","initialize":"2025-11-25","calls":[{"ms":16,"outcome":"tool-error","code":"cleanup_incomplete","reason":"cleanup_failed","message":"Browser scenario provider is closing."},{"ms":2,"outcome":"tool-error","code":"cleanup_incomplete","reason":"cleanup_failed","message":"Browser scenario provider is closing."}],"server_exit":0}
{"mode":"probe-then-modern","discover":"answered","calls":[{"ms":1597,"outcome":"pass"},{"ms":1965,"outcome":"pass"}],"server_exit":0}
{"mode":"cli","calls":[{"ms":4242,"outcome":"pass"}],"exit":0}

Results of two complete runs with this script; every MCP mode used a fresh process per run:

Mode Opening sequence Calls Result tools/call latency
legacy-only legacy initialize (2025-11-25) 4 4/4 pass 1.0–1.9 s
probe-then-legacy server/discover answered, then legacy initialize 4 4/4 cleanup_incomplete first 12 ms, second 2–3 ms
probe-unanswered-then-legacy server/discover, then legacy initialize without waiting for the answer 4 4/4 cleanup_incomplete first 16 ms, second 2–3 ms
probe-then-modern server/discover, then 2026-07-28 tools/call 4 4/4 pass 1.6–2.0 s
cli rea capture-browser-scenario 2 2/2 pass 4.2 s

Counting earlier iterations of the script and runs of the script as extracted from this issue, in both a POSIX shell and PowerShell, every run gave the same outcome. That is 10 fresh processes each for legacy-only and probe-then-legacy, 9 for probe-then-modern, and 5 for probe-unanswered-then-legacy.

repro-probe-discard.mjs
// repro-probe-discard.mjs
// Reproduction for rea-agents 6.4.0: over MCP stdio, capture_browser_scenario fails with
// "Browser scenario provider is closing." after the SDK discards the server/discover probe
// instance because a legacy initialize arrived.
//
// Requirements: Node.js >= 22, an installed rea-agents package, a local Chrome or Chromium
// executable. No other dependencies. Each MCP mode starts a fresh `rea mcp` process and then
// calls capture_browser_scenario twice against a fictional page that this script serves on
// 127.0.0.1. The cli mode runs the same scenario once through `rea capture-browser-scenario`.
//
// Usage:
//   node repro-probe-discard.mjs <path/to/rea-agents/scripts/rea.mjs> <absolute/path/to/browser> [mode ...]
//
// Modes (default: all five, in this order):
//   legacy-only                   legacy initialize only (no probe)
//   probe-then-legacy             server/discover, wait for its answer, then legacy initialize
//   probe-unanswered-then-legacy  server/discover, then legacy initialize without waiting for the answer
//   probe-then-modern             server/discover, then 2026-07-28 requests (the probe instance stays pinned)
//   cli                           the same scenario through the CLI (no MCP)

import { spawn } from "node:child_process";
import { createServer } from "node:http";
import { createInterface } from "node:readline";

const MODES = ["legacy-only", "probe-then-legacy", "probe-unanswered-then-legacy", "probe-then-modern", "cli"];
const [reaEntry, browserPath, ...requestedModes] = process.argv.slice(2);
const modes = requestedModes.length > 0 ? requestedModes : MODES;
if (reaEntry === undefined || browserPath === undefined || modes.some((mode) => !MODES.includes(mode))) {
  console.error(`usage: node repro-probe-discard.mjs <rea.mjs> <browser-executable> [${MODES.join(" | ")} ...]`);
  process.exit(2);
}

// Fictional fixture: clicking the button doubles the input and marks #result as ready.
const PAGE = `<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>REA scenario fixture</title></head>
<body>
<input id="value" type="number" value="2">
<button id="double" type="button">Double</button>
<p id="result" data-state="idle">idle</p>
<script>
document.getElementById("double").addEventListener("click", () => {
  const result = document.getElementById("result");
  result.textContent = "result: " + Number(document.getElementById("value").value) * 2;
  result.dataset.state = "ready";
});
</script>
</body>
</html>`;
const EXPECTED_TEXT = "result: 8";

const fixture = createServer((request, response) => {
  if (request.method === "GET" && request.url === "/") {
    response.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
    response.end(PAGE);
    return;
  }
  response.writeHead(404);
  response.end();
});
await new Promise((resolve) => fixture.listen(0, "127.0.0.1", resolve));

const scenario = {
  browser: { mode: "launch", executable_path: browserPath, headless: true },
  start_url: { url: `http://127.0.0.1:${fixture.address().port}/` },
  actions: [
    { step_id: "fill", action: "fill", locator: { kind: "css", selector: "#value" }, value: { source: "literal", value: "4" }, timeout_ms: 10_000 },
    { step_id: "click", action: "click", locator: { kind: "css", selector: "#double" }, timeout_ms: 10_000 },
    { step_id: "ready", action: "wait_for", locator: { kind: "css", selector: "#result[data-state='ready']" }, state: "visible", timeout_ms: 10_000 },
  ],
  capture: { at_end: ["url", "dom"] },
};

const envelope = {
  "io.modelcontextprotocol/protocolVersion": "2026-07-28",
  "io.modelcontextprotocol/clientCapabilities": {},
  "io.modelcontextprotocol/clientInfo": { name: "probe-repro", version: "0.0.0" },
};

const summarizeCall = (response, ms) => {
  if (response.timeout) return { ms, outcome: "timeout" };
  if (response.exited) return { ms, outcome: "server-exited" };
  if (response.error) return { ms, outcome: "jsonrpc-error", code: response.error.code };
  const result = response.result ?? {};
  const text = (result.content ?? []).map((part) => part.text ?? "").join("\n");
  if (result.isError === true) {
    let error;
    try { error = JSON.parse(text).error; } catch { error = result.structuredContent?.error; }
    return { ms, outcome: "tool-error", code: error?.code ?? null, reason: error?.details?.reason ?? null, message: error?.message ?? null };
  }
  const everything = `${text}\n${JSON.stringify(result.structuredContent ?? null)}`;
  return { ms, outcome: everything.includes(EXPECTED_TEXT) ? "pass" : "completed-without-expected-text" };
};

const runMode = async (mode) => {
  const child = spawn(process.execPath, [reaEntry, "mcp"], { stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
  child.stderr.resume();
  const pending = new Map();
  const exited = new Promise((resolve) => {
    child.on("exit", (code) => resolve(code));
    child.on("error", (error) => resolve(`spawn-error: ${error.message}`));
  });
  void exited.then(() => {
    for (const settle of pending.values()) settle({ exited: true });
    pending.clear();
  });
  createInterface({ input: child.stdout }).on("line", (line) => {
    let message;
    try { message = JSON.parse(line); } catch { return; }
    const settle = pending.get(message.id);
    if (settle !== undefined) {
      pending.delete(message.id);
      settle(message);
    }
  });
  const send = (message) => child.stdin.write(`${JSON.stringify(message)}\n`);
  const request = (id, method, params, timeoutMs) => new Promise((resolve) => {
    const timer = setTimeout(() => { pending.delete(id); resolve({ timeout: true }); }, timeoutMs);
    pending.set(id, (message) => { clearTimeout(timer); resolve(message); });
    send({ jsonrpc: "2.0", id, method, params });
  });
  const status = (response) => response.error ? `error ${response.error.code}` : response.timeout ? "timeout" : "server-exited";

  const out = { mode };
  if (mode === "probe-unanswered-then-legacy") {
    send({ jsonrpc: "2.0", id: 1, method: "server/discover", params: { _meta: envelope } });
    out.discover = "sent, not awaited";
  } else if (mode !== "legacy-only") {
    const discover = await request(1, "server/discover", { _meta: envelope }, 60_000);
    out.discover = discover.result ? "answered" : status(discover);
  }
  const modern = mode === "probe-then-modern";
  if (!modern) {
    const init = await request(2, "initialize", {
      protocolVersion: "2025-11-25",
      capabilities: {},
      clientInfo: { name: "probe-repro", version: "0.0.0" },
    }, 60_000);
    out.initialize = init.result ? init.result.protocolVersion : status(init);
    send({ jsonrpc: "2.0", method: "notifications/initialized" });
  }
  out.calls = [];
  for (const id of [3, 4]) {
    const started = Date.now();
    const response = await request(id, "tools/call", {
      name: "capture_browser_scenario",
      arguments: scenario,
      ...(modern ? { _meta: envelope } : {}),
    }, 180_000);
    out.calls.push(summarizeCall(response, Date.now() - started));
  }
  child.stdin.end();
  const killTimer = setTimeout(() => child.kill(), 15_000);
  out.server_exit = await exited;
  clearTimeout(killTimer);
  return out;
};

const runCli = async () => {
  const started = Date.now();
  const child = spawn(process.execPath, [reaEntry, "capture-browser-scenario", JSON.stringify(scenario)], {
    stdio: ["ignore", "pipe", "pipe"],
    windowsHide: true,
  });
  let output = "";
  child.stdout.on("data", (chunk) => { output += chunk; });
  child.stderr.on("data", (chunk) => { output += chunk; });
  const exit = await new Promise((resolve) => {
    child.on("exit", (code) => resolve(code));
    child.on("error", (error) => resolve(`spawn-error: ${error.message}`));
  });
  const outcome = output.includes("Browser scenario provider is closing")
    ? "provider-closing"
    : output.includes(EXPECTED_TEXT) ? "pass" : "completed-without-expected-text";
  return { mode: "cli", calls: [{ ms: Date.now() - started, outcome }], exit };
};

for (const mode of modes) console.log(JSON.stringify(mode === "cli" ? await runCli() : await runMode(mode)));
fixture.close();

Evidence from the original environment (Claude Code)

The failure first appeared in Claude Code 2.1.296 after a cold start. Client log, with session IDs and paths removed:

Successfully connected (transport: stdio) in 6826ms
Connection established with capabilities: {... "serverVersion":{"name":"rea","version":"6.4.0"},"protocolEra":"legacy","negotiatedProtocolVersion":"2025-11-25"}
stdio server answered the server/discover probe after its bound, so it was only slow to start and speaks the new protocol; not remembering it as a legacy peer

This is the sequence that probe-unanswered-then-legacy replays: the client sends server/discover, does not get an answer within its deadline, and continues with a legacy initialize on the same connection. The CLI ran the same kind of scenario successfully on the same installation: every step completed, the expected final DOM was captured, and it reported cleanup: terminated-owned-process.

repro-probe-discard.txt

Expected behavior

Expected behavior

The opening sequence of an MCP connection should not change tool availability. Discarding the discovery-probe instance should release only resources owned by that instance. capture_browser_scenario should then run the scenario and return Evidence, as it does after a legacy-only or modern opening and in the CLI.

Impact and scope

Impact and scope

  • Affected: capture_browser_scenario over MCP stdio, in both launch and connect modes. The failure lasts for the whole lifetime of a server process whose connection opened with server/discover followed by a legacy initialize. This is verified with the script for an answered and an unanswered probe, and was observed with Claude Code after a 6.8 s cold start.
  • Not affected (verified): the CLI, the other browser/CDP observation tools in the same process, the Ghidra tools in the same process, legacy-only openings, and openings that keep the probe instance through the 2026-07-28 protocol.
  • Integrity and isolation: no partial Evidence is produced and no browser process is started or left behind.
  • Recovery: only a new server process helps, and only if its opening does not discard the probe instance. The error itself is misleading on this point. It reports cleanup_incomplete with execution_failure: unreadable_output and retryable: false, although no cleanup ran for the call, nothing was executed, and the actual state is a permanently closed provider.

REA version

6.4.0

Runtime and platform

Node.js 24.11.1, npm 11.6.2, Windows 11 Home x64, Chrome 155

Target and artifact context

Fictional local static webpage served on 127.0.0.1

Interface

MCP

Earliest failure stage

Cleanup, cancellation, or lifecycle

Relevant output or evidence

REA 6.4.0 — capture_browser_scenario via MCP stdio

Actual MCP tool error:

{"error":{"code":"cleanup_incomplete","category":"execution_failure","message":"Browser scenario provider is closing.","retryable":false,"remediation":{"action":"Browser scenario provider is closing."},"details":{"operation":"capture_browser_scenario","reason":"cleanup_failed","cleanup":"incomplete","resources":[],"cleanup_reason":"Provider is closing","execution_failure":"unreadable_output"}}}

Reproduction results (two script runs, two MCP tool calls per mode):

- legacy-only: 4/4 PASS
- probe-then-legacy: 4/4 FAIL — cleanup_incomplete
- probe-unanswered-then-legacy: 4/4 FAIL — cleanup_incomplete
- probe-then-modern: 4/4 PASS
- CLI: 2/2 PASS

Failed MCP calls returned within 1–16 ms, before any browser process was launched. Subsequent capture_browser_scenario calls in the same server process also failed.

Other MCP tools remained operational, including list_browser_targets, inspect_web_page, analyze_web_bundle and capture_web_screenshot.

Claude Code 2.1.296 reported a 6,826 ms cold connection followed by a fallback to legacy MCP initialization. The reproduction harness demonstrated that a server/discover probe followed by legacy initialize triggers the failure consistently.

All tests used a fictional local webpage. No external websites or private data were involved.

Typed outcome or recovery expected

Ideally, the browser scenario should execute successfully and return the expected Evidence after either supported MCP connection-opening sequence.

If the provider is genuinely unavailable, REA should return a typed provider-unavailable or lifecycle error with an actionable recovery procedure, such as restarting the MCP server or using the equivalent CLI command.

The current error is misleading:

  • Public error code: cleanup_incomplete.
  • Internal reason: cleanup_failed.
  • Underlying execution failure: unreadable_output.
  • Retryable: false.
  • Remediation: repeats Browser scenario provider is closing.

In this case, no browser operation or cleanup attempt occurred. The actual state is a permanently closed browser scenario provider within the affected server process.

The error should identify that condition rather than suggesting incomplete cleanup or unreadable browser output.

Analysis or suspected cause

Experimentally verified

  1. On a fresh REA MCP process, the connection-opening sequence determines whether capture_browser_scenario works.
  2. Both an answered and an unanswered server/discover probe followed by legacy initialize trigger the failure.
  3. Legacy-only initialization and discovery followed by modern MCP requests succeed.
  4. The failing calls return before browser execution and fail again on subsequent attempts in the same process.
  5. The equivalent CLI browser scenario succeeds.

Source-derived analysis (REA 6.4.0)

Inspection of the published package indicates the following lifecycle interaction:

  1. src/main/transport.ts loads optional observation providers once per process and shares them across server instances created by the serveStdio factory.
  2. src/server/createServer.ts includes options.browserScenarioCapture?.close?.() in its closeProviders() routine, which runs when a server instance closes.
  3. In @modelcontextprotocol/server 2.3.1, a legacy opening message following server/discover can discard the discovery-probe instance and invoke its close routine.
  4. PlaywrightBrowserScenarioProvider.close() sets a private closing flag that is not reset. Subsequent calls to captureScenario() return the observed closed-provider error.
  5. The CLI creates a fresh provider for each invocation, which explains why the equivalent scenario continues to work outside MCP.

These findings indicate that a process-scoped browser scenario provider is being closed during cleanup of a temporary server instance, leaving the replacement server with a provider that can no longer be used.

Hypotheses and unverified scope

  • The same defect may affect other operating systems, but testing was performed only on Windows.
  • The inspected development branch appears to retain the relevant lifecycle behavior, but that branch was not built or executed for verification.
  • A faster Claude Code reconnect may avoid the legacy fallback, but this has not been verified through Claude Code.
  • The HTTP transport and possible effects on Electron scenarios were not investigated.

Potential fixes

  • Create the browser scenario provider separately for each MCP server instance, so discarding a probe instance closes only its own resources.
  • Alternatively, manage shared providers at process scope and close them only during final process shutdown, with appropriate lifecycle ownership or reference counting.
  • Add a regression test covering server/discover followed by legacy initialize, including both answered and unanswered probe variants.

The source-derived explanation is consistent with the experimentally reproduced behavior, but the maintainers should confirm the appropriate lifecycle fix.

Additional context

I searched existing open and closed issues and pull requests. No exact duplicate was identified.

  • #1065 — MCP usability and reliability roadmap.
  • #1167 — Client identity and server/discover handling.
  • #1691 — MCP startup optimization and optional provider loading.
  • #1771 / #1773 — Browser scenario cleanup behavior through the CLI.

These are related but address different failure mechanisms.

Standalone reproduction

A dependency-free Node.js reproduction script is attached as repro-probe-discard.txt. Rename it to repro-probe-discard.mjs before running.

The script creates a local fictional webpage, starts fresh MCP server processes, exercises five opening modes, and reports whether browser scenario calls succeed.

Suggested regression coverage

Test the complete MCP stdio lifecycle using:

  • Legacy initialization without discovery.
  • Answered discovery followed by legacy initialization.
  • Unanswered discovery followed by legacy initialization.
  • Discovery followed by modern MCP requests.
  • Repeated capture_browser_scenario calls within each process.

Verify that discarding a discovery-probe server instance cannot close providers still required by the active server.

Workaround

Until the lifecycle issue is fixed, run browser scenarios through the REA CLI:

rea capture-browser-scenario <scenario.json>

The other browser observation and native-analysis MCP tools continue to work normally.

Originally created by @JackOdegard on GitHub (Oct 12, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/2195 ### Before submitting - [x] I searched the open and closed issues for a related report. - [x] I removed credentials, tokens, private paths, and sensitive target data. ### Area MCP or agent integration ### Observed behavior ### Summary Over MCP stdio, the outcome depends on how the connection opens: - If it opens with a `server/discover` probe and then falls back to a legacy `initialize`, every `capture_browser_scenario` call on that server process fails with `code: "cleanup_incomplete"` and the message `Browser scenario provider is closing.` - If it opens with a legacy `initialize` only, or keeps the probe instance through the 2026-07-28 protocol, the same scenario passes. - The CLI also passes. The source shows why. The process-wide browser scenario provider is closed when the SDK discards the probe server instance. ### Expected behavior The opening sequence of an MCP connection should not change tool availability. Discarding the discovery-probe instance should release only resources owned by that instance. `capture_browser_scenario` should then run the scenario and return Evidence, as it does after a legacy-only or modern opening and in the CLI. ### Actual behavior Every call fails before any browser work, with this tool error: ```json {"error":{"code":"cleanup_incomplete","category":"execution_failure","message":"Browser scenario provider is closing.","retryable":false,"remediation":{"action":"Browser scenario provider is closing."},"details":{"operation":"capture_browser_scenario","reason":"cleanup_failed","cleanup":"incomplete","resources":[],"cleanup_reason":"Provider is closing","execution_failure":"unreadable_output"}}} ``` - The call returns in 1–16 ms across all runs of the script below. In Claude Code the client measured 75 ms and 22 ms. No browser process is started. - The failure persists within the process. In the script, the second call on the same process fails the same way. In Claude Code, two calls 3 min 50 s apart failed: one in `launch` mode and one in `connect` mode against an already running loopback CDP target. - Other tools keep working in the affected Claude Code server process: `list_browser_targets`, `inspect_web_page`, `analyze_web_bundle`, `inspect_web_event_listeners`, `capture_web_screenshot`, and the Ghidra native-analysis tools. How each error field arises in 6.4.0 (source-derived; the values are observed): | Field | Value | Origin | | --- | --- | --- | | `details.reason` | `cleanup_failed` | Internal `BrowserObservationError.reason`, chosen by `PlaywrightBrowserScenarioProvider#closedError()` once the provider is closing. | | `code` | `cleanup_incomplete` | `projectAnalysisError()` replaces the underlying code with `cleanup_incomplete` whenever the error sets `cleanupIncomplete`. `BrowserObservationError` sets that flag for reason `cleanup_failed` or when a cleanup record is attached. | | `details.cleanup`, `details.resources`, `details.cleanup_reason` | `incomplete`, `[]`, `Provider is closing` | Projected because `cleanupIncomplete` is set, from the cleanup record that `#closedError()` attaches (`reason: "Provider is closing"`, `resources: []`). | | `details.execution_failure` | `unreadable_output` | The underlying code. `browserErrorCode()` falls back to `unreadable_output` for browser reasons it does not map, including `cleanup_failed`. | | `category` | `execution_failure` | Derived by `analysisErrorCategory()` from the browser reason. | | `retryable` | `false` | `cleanup_incomplete` is not in the retryable code set. | | `message`, `remediation.action` | `Browser scenario provider is closing.` | The provider's detail string. The observed remediation repeats the message. | ### Reproduction ### Reproduction You need Node.js 22 or later, an installed `rea-agents` package and a local Chrome or Chromium executable. The script below has no dependencies. It serves its own fictional page on `127.0.0.1`. For each MCP mode it starts a fresh `rea mcp` process, opens it with one sequence, and calls `capture_browser_scenario` twice. The `cli` mode runs the same scenario through `rea capture-browser-scenario`. 1. Save the script below as `repro-probe-discard.mjs`. 2. Run it with the REA entry point and an absolute browser path. The command works the same in a POSIX shell and in PowerShell: ```sh node repro-probe-discard.mjs "$(npm root -g)/rea-agents/scripts/rea.mjs" "<absolute-path-to-chrome-or-chromium>" ``` 3. Read the one-line JSON result per mode. Output on the system above: ```text {"mode":"legacy-only","initialize":"2025-11-25","calls":[{"ms":1831,"outcome":"pass"},{"ms":1871,"outcome":"pass"}],"server_exit":0} {"mode":"probe-then-legacy","discover":"answered","initialize":"2025-11-25","calls":[{"ms":12,"outcome":"tool-error","code":"cleanup_incomplete","reason":"cleanup_failed","message":"Browser scenario provider is closing."},{"ms":2,"outcome":"tool-error","code":"cleanup_incomplete","reason":"cleanup_failed","message":"Browser scenario provider is closing."}],"server_exit":0} {"mode":"probe-unanswered-then-legacy","discover":"sent, not awaited","initialize":"2025-11-25","calls":[{"ms":16,"outcome":"tool-error","code":"cleanup_incomplete","reason":"cleanup_failed","message":"Browser scenario provider is closing."},{"ms":2,"outcome":"tool-error","code":"cleanup_incomplete","reason":"cleanup_failed","message":"Browser scenario provider is closing."}],"server_exit":0} {"mode":"probe-then-modern","discover":"answered","calls":[{"ms":1597,"outcome":"pass"},{"ms":1965,"outcome":"pass"}],"server_exit":0} {"mode":"cli","calls":[{"ms":4242,"outcome":"pass"}],"exit":0} ``` Results of two complete runs with this script; every MCP mode used a fresh process per run: | Mode | Opening sequence | Calls | Result | `tools/call` latency | | --- | --- | --- | --- | --- | | `legacy-only` | legacy `initialize` (2025-11-25) | 4 | 4/4 pass | 1.0–1.9 s | | `probe-then-legacy` | `server/discover` answered, then legacy `initialize` | 4 | 4/4 `cleanup_incomplete` | first 12 ms, second 2–3 ms | | `probe-unanswered-then-legacy` | `server/discover`, then legacy `initialize` without waiting for the answer | 4 | 4/4 `cleanup_incomplete` | first 16 ms, second 2–3 ms | | `probe-then-modern` | `server/discover`, then 2026-07-28 `tools/call` | 4 | 4/4 pass | 1.6–2.0 s | | `cli` | `rea capture-browser-scenario` | 2 | 2/2 pass | 4.2 s | Counting earlier iterations of the script and runs of the script as extracted from this issue, in both a POSIX shell and PowerShell, every run gave the same outcome. That is 10 fresh processes each for `legacy-only` and `probe-then-legacy`, 9 for `probe-then-modern`, and 5 for `probe-unanswered-then-legacy`. <details> <summary><code>repro-probe-discard.mjs</code></summary> ```js // repro-probe-discard.mjs // Reproduction for rea-agents 6.4.0: over MCP stdio, capture_browser_scenario fails with // "Browser scenario provider is closing." after the SDK discards the server/discover probe // instance because a legacy initialize arrived. // // Requirements: Node.js >= 22, an installed rea-agents package, a local Chrome or Chromium // executable. No other dependencies. Each MCP mode starts a fresh `rea mcp` process and then // calls capture_browser_scenario twice against a fictional page that this script serves on // 127.0.0.1. The cli mode runs the same scenario once through `rea capture-browser-scenario`. // // Usage: // node repro-probe-discard.mjs <path/to/rea-agents/scripts/rea.mjs> <absolute/path/to/browser> [mode ...] // // Modes (default: all five, in this order): // legacy-only legacy initialize only (no probe) // probe-then-legacy server/discover, wait for its answer, then legacy initialize // probe-unanswered-then-legacy server/discover, then legacy initialize without waiting for the answer // probe-then-modern server/discover, then 2026-07-28 requests (the probe instance stays pinned) // cli the same scenario through the CLI (no MCP) import { spawn } from "node:child_process"; import { createServer } from "node:http"; import { createInterface } from "node:readline"; const MODES = ["legacy-only", "probe-then-legacy", "probe-unanswered-then-legacy", "probe-then-modern", "cli"]; const [reaEntry, browserPath, ...requestedModes] = process.argv.slice(2); const modes = requestedModes.length > 0 ? requestedModes : MODES; if (reaEntry === undefined || browserPath === undefined || modes.some((mode) => !MODES.includes(mode))) { console.error(`usage: node repro-probe-discard.mjs <rea.mjs> <browser-executable> [${MODES.join(" | ")} ...]`); process.exit(2); } // Fictional fixture: clicking the button doubles the input and marks #result as ready. const PAGE = `<!doctype html> <html lang="en"> <head><meta charset="utf-8"><title>REA scenario fixture</title></head> <body> <input id="value" type="number" value="2"> <button id="double" type="button">Double</button> <p id="result" data-state="idle">idle</p> <script> document.getElementById("double").addEventListener("click", () => { const result = document.getElementById("result"); result.textContent = "result: " + Number(document.getElementById("value").value) * 2; result.dataset.state = "ready"; }); </script> </body> </html>`; const EXPECTED_TEXT = "result: 8"; const fixture = createServer((request, response) => { if (request.method === "GET" && request.url === "/") { response.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" }); response.end(PAGE); return; } response.writeHead(404); response.end(); }); await new Promise((resolve) => fixture.listen(0, "127.0.0.1", resolve)); const scenario = { browser: { mode: "launch", executable_path: browserPath, headless: true }, start_url: { url: `http://127.0.0.1:${fixture.address().port}/` }, actions: [ { step_id: "fill", action: "fill", locator: { kind: "css", selector: "#value" }, value: { source: "literal", value: "4" }, timeout_ms: 10_000 }, { step_id: "click", action: "click", locator: { kind: "css", selector: "#double" }, timeout_ms: 10_000 }, { step_id: "ready", action: "wait_for", locator: { kind: "css", selector: "#result[data-state='ready']" }, state: "visible", timeout_ms: 10_000 }, ], capture: { at_end: ["url", "dom"] }, }; const envelope = { "io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientCapabilities": {}, "io.modelcontextprotocol/clientInfo": { name: "probe-repro", version: "0.0.0" }, }; const summarizeCall = (response, ms) => { if (response.timeout) return { ms, outcome: "timeout" }; if (response.exited) return { ms, outcome: "server-exited" }; if (response.error) return { ms, outcome: "jsonrpc-error", code: response.error.code }; const result = response.result ?? {}; const text = (result.content ?? []).map((part) => part.text ?? "").join("\n"); if (result.isError === true) { let error; try { error = JSON.parse(text).error; } catch { error = result.structuredContent?.error; } return { ms, outcome: "tool-error", code: error?.code ?? null, reason: error?.details?.reason ?? null, message: error?.message ?? null }; } const everything = `${text}\n${JSON.stringify(result.structuredContent ?? null)}`; return { ms, outcome: everything.includes(EXPECTED_TEXT) ? "pass" : "completed-without-expected-text" }; }; const runMode = async (mode) => { const child = spawn(process.execPath, [reaEntry, "mcp"], { stdio: ["pipe", "pipe", "pipe"], windowsHide: true }); child.stderr.resume(); const pending = new Map(); const exited = new Promise((resolve) => { child.on("exit", (code) => resolve(code)); child.on("error", (error) => resolve(`spawn-error: ${error.message}`)); }); void exited.then(() => { for (const settle of pending.values()) settle({ exited: true }); pending.clear(); }); createInterface({ input: child.stdout }).on("line", (line) => { let message; try { message = JSON.parse(line); } catch { return; } const settle = pending.get(message.id); if (settle !== undefined) { pending.delete(message.id); settle(message); } }); const send = (message) => child.stdin.write(`${JSON.stringify(message)}\n`); const request = (id, method, params, timeoutMs) => new Promise((resolve) => { const timer = setTimeout(() => { pending.delete(id); resolve({ timeout: true }); }, timeoutMs); pending.set(id, (message) => { clearTimeout(timer); resolve(message); }); send({ jsonrpc: "2.0", id, method, params }); }); const status = (response) => response.error ? `error ${response.error.code}` : response.timeout ? "timeout" : "server-exited"; const out = { mode }; if (mode === "probe-unanswered-then-legacy") { send({ jsonrpc: "2.0", id: 1, method: "server/discover", params: { _meta: envelope } }); out.discover = "sent, not awaited"; } else if (mode !== "legacy-only") { const discover = await request(1, "server/discover", { _meta: envelope }, 60_000); out.discover = discover.result ? "answered" : status(discover); } const modern = mode === "probe-then-modern"; if (!modern) { const init = await request(2, "initialize", { protocolVersion: "2025-11-25", capabilities: {}, clientInfo: { name: "probe-repro", version: "0.0.0" }, }, 60_000); out.initialize = init.result ? init.result.protocolVersion : status(init); send({ jsonrpc: "2.0", method: "notifications/initialized" }); } out.calls = []; for (const id of [3, 4]) { const started = Date.now(); const response = await request(id, "tools/call", { name: "capture_browser_scenario", arguments: scenario, ...(modern ? { _meta: envelope } : {}), }, 180_000); out.calls.push(summarizeCall(response, Date.now() - started)); } child.stdin.end(); const killTimer = setTimeout(() => child.kill(), 15_000); out.server_exit = await exited; clearTimeout(killTimer); return out; }; const runCli = async () => { const started = Date.now(); const child = spawn(process.execPath, [reaEntry, "capture-browser-scenario", JSON.stringify(scenario)], { stdio: ["ignore", "pipe", "pipe"], windowsHide: true, }); let output = ""; child.stdout.on("data", (chunk) => { output += chunk; }); child.stderr.on("data", (chunk) => { output += chunk; }); const exit = await new Promise((resolve) => { child.on("exit", (code) => resolve(code)); child.on("error", (error) => resolve(`spawn-error: ${error.message}`)); }); const outcome = output.includes("Browser scenario provider is closing") ? "provider-closing" : output.includes(EXPECTED_TEXT) ? "pass" : "completed-without-expected-text"; return { mode: "cli", calls: [{ ms: Date.now() - started, outcome }], exit }; }; for (const mode of modes) console.log(JSON.stringify(mode === "cli" ? await runCli() : await runMode(mode))); fixture.close(); ``` </details> ### Evidence from the original environment (Claude Code) The failure first appeared in Claude Code 2.1.296 after a cold start. Client log, with session IDs and paths removed: ```text Successfully connected (transport: stdio) in 6826ms Connection established with capabilities: {... "serverVersion":{"name":"rea","version":"6.4.0"},"protocolEra":"legacy","negotiatedProtocolVersion":"2025-11-25"} stdio server answered the server/discover probe after its bound, so it was only slow to start and speaks the new protocol; not remembering it as a legacy peer ``` This is the sequence that `probe-unanswered-then-legacy` replays: the client sends `server/discover`, does not get an answer within its deadline, and continues with a legacy `initialize` on the same connection. The CLI ran the same kind of scenario successfully on the same installation: every step completed, the expected final DOM was captured, and it reported `cleanup: terminated-owned-process`. [repro-probe-discard.txt](https://github.com/user-attachments/files/33338988/repro-probe-discard.txt) ### Expected behavior ### Expected behavior The opening sequence of an MCP connection should not change tool availability. Discarding the discovery-probe instance should release only resources owned by that instance. `capture_browser_scenario` should then run the scenario and return Evidence, as it does after a legacy-only or modern opening and in the CLI. ### Impact and scope ### Impact and scope - **Affected:** `capture_browser_scenario` over MCP stdio, in both `launch` and `connect` modes. The failure lasts for the whole lifetime of a server process whose connection opened with `server/discover` followed by a legacy `initialize`. This is verified with the script for an answered and an unanswered probe, and was observed with Claude Code after a 6.8 s cold start. - **Not affected (verified):** the CLI, the other browser/CDP observation tools in the same process, the Ghidra tools in the same process, legacy-only openings, and openings that keep the probe instance through the 2026-07-28 protocol. - **Integrity and isolation:** no partial Evidence is produced and no browser process is started or left behind. - **Recovery:** only a new server process helps, and only if its opening does not discard the probe instance. The error itself is misleading on this point. It reports `cleanup_incomplete` with `execution_failure: unreadable_output` and `retryable: false`, although no cleanup ran for the call, nothing was executed, and the actual state is a permanently closed provider. ### REA version 6.4.0 ### Runtime and platform Node.js 24.11.1, npm 11.6.2, Windows 11 Home x64, Chrome 155 ### Target and artifact context Fictional local static webpage served on 127.0.0.1 ### Interface MCP ### Earliest failure stage Cleanup, cancellation, or lifecycle ### Relevant output or evidence ```text REA 6.4.0 — capture_browser_scenario via MCP stdio Actual MCP tool error: {"error":{"code":"cleanup_incomplete","category":"execution_failure","message":"Browser scenario provider is closing.","retryable":false,"remediation":{"action":"Browser scenario provider is closing."},"details":{"operation":"capture_browser_scenario","reason":"cleanup_failed","cleanup":"incomplete","resources":[],"cleanup_reason":"Provider is closing","execution_failure":"unreadable_output"}}} Reproduction results (two script runs, two MCP tool calls per mode): - legacy-only: 4/4 PASS - probe-then-legacy: 4/4 FAIL — cleanup_incomplete - probe-unanswered-then-legacy: 4/4 FAIL — cleanup_incomplete - probe-then-modern: 4/4 PASS - CLI: 2/2 PASS Failed MCP calls returned within 1–16 ms, before any browser process was launched. Subsequent capture_browser_scenario calls in the same server process also failed. Other MCP tools remained operational, including list_browser_targets, inspect_web_page, analyze_web_bundle and capture_web_screenshot. Claude Code 2.1.296 reported a 6,826 ms cold connection followed by a fallback to legacy MCP initialization. The reproduction harness demonstrated that a server/discover probe followed by legacy initialize triggers the failure consistently. All tests used a fictional local webpage. No external websites or private data were involved. ``` ### Typed outcome or recovery expected Ideally, the browser scenario should execute successfully and return the expected Evidence after either supported MCP connection-opening sequence. If the provider is genuinely unavailable, REA should return a typed provider-unavailable or lifecycle error with an actionable recovery procedure, such as restarting the MCP server or using the equivalent CLI command. The current error is misleading: - Public error code: `cleanup_incomplete`. - Internal reason: `cleanup_failed`. - Underlying execution failure: `unreadable_output`. - Retryable: `false`. - Remediation: repeats `Browser scenario provider is closing.` In this case, no browser operation or cleanup attempt occurred. The actual state is a permanently closed browser scenario provider within the affected server process. The error should identify that condition rather than suggesting incomplete cleanup or unreadable browser output. ### Analysis or suspected cause ### Experimentally verified 1. On a fresh REA MCP process, the connection-opening sequence determines whether `capture_browser_scenario` works. 2. Both an answered and an unanswered `server/discover` probe followed by legacy `initialize` trigger the failure. 3. Legacy-only initialization and discovery followed by modern MCP requests succeed. 4. The failing calls return before browser execution and fail again on subsequent attempts in the same process. 5. The equivalent CLI browser scenario succeeds. ### Source-derived analysis (REA 6.4.0) Inspection of the published package indicates the following lifecycle interaction: 1. `src/main/transport.ts` loads optional observation providers once per process and shares them across server instances created by the `serveStdio` factory. 2. `src/server/createServer.ts` includes `options.browserScenarioCapture?.close?.()` in its `closeProviders()` routine, which runs when a server instance closes. 3. In `@modelcontextprotocol/server` 2.3.1, a legacy opening message following `server/discover` can discard the discovery-probe instance and invoke its close routine. 4. `PlaywrightBrowserScenarioProvider.close()` sets a private closing flag that is not reset. Subsequent calls to `captureScenario()` return the observed closed-provider error. 5. The CLI creates a fresh provider for each invocation, which explains why the equivalent scenario continues to work outside MCP. These findings indicate that a process-scoped browser scenario provider is being closed during cleanup of a temporary server instance, leaving the replacement server with a provider that can no longer be used. ### Hypotheses and unverified scope - The same defect may affect other operating systems, but testing was performed only on Windows. - The inspected development branch appears to retain the relevant lifecycle behavior, but that branch was not built or executed for verification. - A faster Claude Code reconnect may avoid the legacy fallback, but this has not been verified through Claude Code. - The HTTP transport and possible effects on Electron scenarios were not investigated. ### Potential fixes - Create the browser scenario provider separately for each MCP server instance, so discarding a probe instance closes only its own resources. - Alternatively, manage shared providers at process scope and close them only during final process shutdown, with appropriate lifecycle ownership or reference counting. - Add a regression test covering `server/discover` followed by legacy `initialize`, including both answered and unanswered probe variants. The source-derived explanation is consistent with the experimentally reproduced behavior, but the maintainers should confirm the appropriate lifecycle fix. ### Additional context ### Related issues and pull requests I searched existing open and closed issues and pull requests. No exact duplicate was identified. - #1065 — MCP usability and reliability roadmap. - #1167 — Client identity and `server/discover` handling. - #1691 — MCP startup optimization and optional provider loading. - #1771 / #1773 — Browser scenario cleanup behavior through the CLI. These are related but address different failure mechanisms. ### Standalone reproduction A dependency-free Node.js reproduction script is attached as `repro-probe-discard.txt`. Rename it to `repro-probe-discard.mjs` before running. The script creates a local fictional webpage, starts fresh MCP server processes, exercises five opening modes, and reports whether browser scenario calls succeed. ### Suggested regression coverage Test the complete MCP stdio lifecycle using: - Legacy initialization without discovery. - Answered discovery followed by legacy initialization. - Unanswered discovery followed by legacy initialization. - Discovery followed by modern MCP requests. - Repeated `capture_browser_scenario` calls within each process. Verify that discarding a discovery-probe server instance cannot close providers still required by the active server. ### Workaround Until the lifecycle issue is fixed, run browser scenarios through the REA CLI: `rea capture-browser-scenario <scenario.json>` The other browser observation and native-analysis MCP tools continue to work normally.
bsakaguchi 2026-10-15 18:27:48 +00:00
  • closed this issue
  • added the
    triage
    bug
    labels
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
morluto/rea#734
No description provided.