Skip to content

feat(core): unified state header on page-state tool responses (BrowserMCP adoption B-2) #893

@shaun0927

Description

@shaun0927

Tier: core (additive output formatter; no schema breaking change)
PR target: develop

Background

Tools that return page state in OpenChrome use inconsistent envelopes:

  • read_page returns mode-specific payloads (ax returns a refs tree; dom returns a compressed DOM string; css returns a styles report).
  • page_content returns either raw HTML (when outerHTML: true) or a JSON envelope when an element is missing.
  • inspect returns a structured object.
  • validate_page returns a checks summary.

None of them prepend a uniform state header identifying (URL, title, mode, capturedAt). The agent must learn each tool's response shape separately and cannot reliably grep "what page is this snapshot from" without parsing the inner payload. This becomes painful once #845 (includeSnapshot chaining) lands and multiple state blobs flow back in a single response.

BrowserMCP/mcp's captureAriaSnapshot (Apache-2.0) wraps every state-returning call with a tiny constant header:

- Page URL: <url>
- Page Title: <title>
- Page Snapshot (mode: ax)
```yaml
<payload>

This is a **text-mode-only** convention — the structured JSON returns are untouched.

**Scoping note (deliberate omission)**: BrowserMCP's header has no analogue of CDP `loaderId` because BrowserMCP has no CDP. A `loaderId`-based staleness marker is genuinely useful for OpenChrome agents but requires new CDP plumbing (subscribe to `Page.frameNavigated`, store per-frame `loaderId`, retrieve at capture). That is a **separate issue**; this one prepends only fields that are already cheap to compute (URL, title, server clock).

**Why this is not redundant with #841**: #841 standardises *input descriptions* in `tools/list`. This issue standardises *output envelopes* in tool *responses*. They share no fields.

**Why this is not redundant with #831**: #831 introduces refs and `STALE_REF` semantics on inputs. This issue prepends a header on outputs. They share no fields.

## Proposed Implementation

1. **New helper** `src/tools/_shared/state-header.ts` (the directory may be created by #845 or this issue, whichever lands first):
   ```ts
   export interface PageStateHeader {
     url: string;
     title: string;
     mode: 'ax' | 'dom' | 'css' | 'html' | 'inspect' | 'validate';
     capturedAt: number;   // unix ms, server wall-clock at response assembly
     tabId: string;
   }
   export function formatHeaderText(h: PageStateHeader): string { /* … */ }
  1. Prepend the header to text-mode responses of:
    • read_page (all three modes)
    • page_content (when returning raw HTML)
    • inspect
    • validate_page
  2. Header format (4 lines, exact):
    - Page URL: <url>
    - Page Title: <title>
    - Page Mode: <mode>
    - Captured At: <ISO-8601 with milliseconds>
    
    followed by a blank line, then the existing payload.
  3. JSON-mode responses untouched. When a tool already returns a structured object (e.g., page_content with missing element, validate_page structured report), the fields are added as a top-level state object:
    {
      "state": { "url": "...", "title": "...", "mode": "html", "capturedAt": 1715000000000, "tabId": "t1" },
      // …existing fields unchanged…
    }
  4. Data sources (all already available — no new CDP plumbing):
    • url: page.url() from puppeteer-core.
    • title: page.title() from puppeteer-core (cached value; cheap).
    • tabId: existing session-manager handle.
    • capturedAt: Date.now() at the moment the response is assembled (post-Ralph wait, if any).
  5. Backward compatibility: env opt-out OPENCHROME_STATE_HEADER=off reverts to pre-PR output byte-exact. Default is on. Snapshot test under tests/fixtures/state-header/ proves byte-identity with v1.11.0 when the env is set to off.
  6. No tool description changes in this issue (description shape lives in refactor(core): standardize tool descriptions with 'When to use / When NOT to use' guidance #841).

Acceptance Criteria

  • src/tools/_shared/state-header.ts exists with the helper and types.
  • All four tools (read_page, page_content, inspect, validate_page) prepend the header (text mode) or add the state object (JSON mode).
  • All four fields populated from existing sources; no new CDP listeners or state tracking introduced.
  • capturedAt is the server-side wall-clock at the moment the response is assembled (post-Ralph wait if any). Verified to be monotonic-non-decreasing across two consecutive calls on the same tab.
  • OPENCHROME_STATE_HEADER=off produces byte-identical output to v1.11.0 (snapshot test).
  • No new dependencies; uses only existing page.url() / page.title() accessors.
  • npm run build && npm test green.
  • PR targets develop.

Real verification (post-merge, via openchrome MCP)

Reproducer at scripts/verify/B2-state-header.mjs.

  1. Build and start: node dist/index.js.
  2. mcp__openchrome__navigate to https://example.com.
  3. mcp__openchrome__read_page mode=ax → response text starts with four lines:
    - Page URL: https://example.com/
    - Page Title: Example Domain
    - Page Mode: ax
    - Captured At: 2026-05-12T...
    
  4. mcp__openchrome__inspect { tabId: <t>, selector: "h1" } → same four header lines before the inspect payload, with Page Mode: inspect.
  5. mcp__openchrome__page_content with a missing selector → response is JSON with a top-level state object containing the four fields plus tabId.
  6. Cross-tool consistency check: capture read_page and inspect responses within 100 ms. Their url and title MUST be identical; their capturedAt MUST differ by < 200 ms and be non-decreasing.
  7. Cheap staleness use case: navigate to https://example.org, then call read_page — the url field MUST differ from step 3's value. (This is what makes the header agent-useful: a URL-equality check is enough to invalidate stale refs without re-fetching the whole tree.)
  8. Backward-compat path:
    OPENCHROME_STATE_HEADER=off node dist/index.js
    Repeat step 3 — response is byte-identical to v1.11.0 output (compared against a checked-in fixture under tests/fixtures/state-header/v1.11.0-read-page-ax.txt).
  9. No CDP regression: launch with OPENCHROME_TRACE_CDP=1; capture the CDP method-name set used during the test session. The set MUST equal the v1.11.0 baseline (no new methods added). Recorded in PR description.

Out of scope (deferred)

  • A frame-load identifier (loaderId-style staleness token) — needs new CDP listeners; file as a follow-up if URL-equality proves insufficient in practice.
  • Network-level fields (response status, content-type) in the header — those belong to oc_evidence_bundle.
  • Per-frame headers for iframe captures (top-frame only in this issue).
  • Adding the header to page_screenshot — agents already get URL/title from CDP image metadata.
  • Removing the env opt-out after a deprecation period — not in this PR.

Dependencies

Effort

S (~1 dev-day). One helper, four tool touch-points, one snapshot fixture. No CDP plumbing.

References

  • BrowserMCP captureAriaSnapshot: BrowserMCP/mcp/src/utils/aria-snapshot.ts (Apache-2.0).
  • Existing accessors: page.url() / page.title() via rebrowser-puppeteer-core.
  • Existing tools updated: src/tools/read-page.ts, src/tools/page-content.ts, src/tools/inspect.ts, src/tools/validate-page.ts.

Revision history

  • 2026-05-12 r1: Initial draft.
  • 2026-05-12 r2: Critic-driven revision.
    • Dropped loaderId from the header — it required new CDP Page.frameNavigated listeners and per-frame state tracking, transforming a 1-day formatter into a CDP-plumbing project. Filed as deferred follow-up.
    • Removed the false "evidence-bundle already carries these fields" claimsrc/core/contracts/evidence-bundle.ts has no context object.
    • Header is now 4 fields populated entirely from already-cheap page.url() / page.title() / Date.now() accessors; the staleness use case is reframed around URL-equality.
    • Added a "no new CDP methods" regression assertion to the verification.

OpenChrome 실검증 체크리스트

2026-05-14 재검증 완료. 최신 origin/develop 코드, targeted Jest/lint, OpenChrome CLI 실호출, localhost fixture 산출물로 직접 확인 가능한 항목만 close 근거로 사용했다.

검증 대상

검증 증거

  • npm run build 통과.
  • npm run lint:tier 통과: 521 modules / 1239 dependencies, no dependency violations.
  • npm run lint:tool-schemas 통과: 82 baselined violations, 0 new.
  • targeted Jest 통과: 38 passed / 1 skipped suites, 436 passed / 1 skipped tests.
  • OpenChrome CLI 실호출: oc_connection_health connected, localhost fixture navigate 성공.
  • OpenChrome tools/list introspection에서 관련 default 또는 pilot-gated tool surface 존재 확인.
  • 대표 bounded diagnostic 호출이 구조화된 성공/오류 응답을 반환함을 확인.

이슈별 코드/테스트 근거

  • 관련 구현/문서/테스트 파일이 최신 트리에 존재하고 targeted 검증에 포함됨:
    • src/tools/_shared/state-header.ts
    • tests/tools/state-header.test.ts
    • src/tools/read-page.ts
    • src/tools/page-content.ts
    • src/tools/inspect.ts
    • src/tools/validate-page.ts

산출물

  • 증거 로그: .omx/reverify-evidence/targeted-jest.log
  • 증거 로그: .omx/reverify-evidence/lint-tier.log
  • 증거 로그: .omx/reverify-evidence/lint-tool-schemas.log
  • 증거 로그: .omx/reverify-evidence/openchrome-live-smoke.log

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Medium priorityenhancementNew feature or requestobservabilityObservabilityrefactorCode structure or maintainability improvement

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions