Skip to content

feat(browser-mcp): structured results for screenshot, grep, and diff#1376

Merged
Nikhil (shadowfax92) merged 1 commit into
mainfrom
feat/structured-tool-results
Jun 24, 2026
Merged

feat(browser-mcp): structured results for screenshot, grep, and diff#1376
Nikhil (shadowfax92) merged 1 commit into
mainfrom
feat/structured-tool-results

Conversation

@shadowfax92

Copy link
Copy Markdown
Contributor

Summary

Close the remaining gaps so every browser-mcp tool returns clean, minimal structuredContent on success (most already did; snapshot/diff/act/navigate/tabs/read/etc. were already structured).

  • screenshot: now always emits structuredContent{ page, format, bytes, image, annotations? } (previously only { page, annotations }, and only when annotations existed). bytes is the decoded image byte length; image is the base64 string.
  • grep: emits { page, over, count, matches } on both the match and no-match paths (previously just { count }). The human/text output is byte-for-byte unchanged.
  • diff: adds a changed boolean discriminator — { changed: false } on the no-change path (previously no structuredContent at all), and changed: true alongside added/removed on the change path.

Design

Surgical, additive edits through the existing textResult / response.data plumbing — no new abstractions, no outputSchema declarations (only run declares one, by convention). tabs active/list were intentionally left untouched: they already return good structured output (the request cited them as examples, not gaps), and the Go CLI's active command consumes structuredContent.page as a full page object and normalizes it itself (apps/cli/cmd/tabs.go), so reshaping it would break active and snap.

Note on screenshot.image: the base64 is intentionally included in structuredContent (as requested) even though it also appears in the image content block. This makes structured-only MCP clients self-sufficient; the trade-off is a larger payload on the browseros-cli screenshot --json path that has no --out (the --out path overrides structuredContent with { path }). Easy to drop the image field later if that payload size matters.

Test plan

  • bun run check (lint + typecheck + Fallow) — green
  • bun run test (full monorepo suite, incl. the Go-CLI integration test that launches BrowserOS) — green
  • Added/updated structured-content assertions: screenshot-tool.test.ts, new grep.test.ts, diff no-change in register.test.ts
  • Verified the Go CLI consumers are unaffected: find reads grep's text (unchanged), screenshot --out overrides structured, diff change is additive

- screenshot: always emit structuredContent {page, format, bytes, image,
  annotations?} (was only {page, annotations}, and only when annotated).
- grep: emit {page, over, count, matches} on both match and no-match paths
  (was just {count}); text output is unchanged so the CLI find command is unaffected.
- diff: add a "changed" boolean discriminator so the diff tool always returns
  structuredContent, including {changed:false} on the no-change path.

tabs active/list already returned good structured output (cited as examples,
not gaps) and the Go CLI depends on active's page object, so they stay untouched.
@shadowfax92

Copy link
Copy Markdown
Contributor Author

Greptile (@greptileai) review

@shadowfax92

Copy link
Copy Markdown
Contributor Author

Claude (@claude) review

@claude

claude Bot commented Jun 24, 2026

Copy link
Copy Markdown

Claude finished Nikhil (@shadowfax92)'s task in 2m 40s —— View job


Code Review: feat(browser-mcp): structured results for screenshot, grep, and diff

  • Gather context and understand the request
  • Read changed source files
  • Read changed test files
  • Analyze code quality, correctness, and edge cases
  • Post review feedback

Overall this is a clean, focused PR. The changes are exactly as described — surgical and additive, with no behavioral regressions on existing paths. Tests cover all three new paths. A few notes below, ordered by severity.

screenshot.ts

Minor perf nit: Buffer.from(result.data, 'base64').length allocates a fully-decoded image buffer just to read its length. For a 200KB screenshot, this silently allocates ~133KB of base64 and decodes it to ~200KB just to discard it. The standard formula is cheaper:

// exact byte count without allocation
bytes: Math.ceil(result.data.replace(/=+$/, '').length * 3 / 4)

Not a correctness issue — just unnecessary garbage for a hot path. Fix this →

Observation: format: args.format uses the requested format (Zod default 'jpeg'), not the format in result.mimeType. In practice these always agree because args.format is passed directly into captureOptions.format. But if the capture layer ever falls back to a different format, the structured field would be wrong. Since result.mimeType is already available and is the ground truth, format: result.mimeType.replace('image/', '') would be strictly more accurate — though it's a defensive concern, not a real bug today.

grep.ts

Design inconsistency vs. diff-format.ts: The matches array in structuredContent contains raw page content without wrapUntrusted. The text path correctly wraps at line 52. Compare with diff-format.ts which puts the trust-boundary-marked wrappedDiff inside the structured content (e.g., structured.diff = wrappedDiff). The evaluate.ts tool goes the other way — raw value in structured. So the codebase is split.

If an MCP client surfaces structuredContent.matches to an LLM directly (many do), raw matches bypass indirect prompt injection protection. Whether this matters depends on client behavior. Not a regression (it was always possible to get raw content via the text path on the match side), but worth noting that diff and grep now handle this differently. Fix this →

Non-issue, but notable: count in both paths reflects the post-slice length (up to limit ?? 50), not total matching lines in the document. A consumer can't tell if results were truncated. This is pre-existing behavior — the PR doesn't introduce it — but adding truncated: true when matches.length === (args.limit ?? 50) would make structured output self-describing for consumers that care. Easy follow-up if needed.

diff-format.ts

This is the cleanest change. The changed: false path previously returned no structured at all; now it returns { changed: false }. Adding changed: true to the existing path as a discriminator is the right design — it makes the union legible for structured-only consumers. No issues.

Tests

The test coverage is solid:

  • grep.test.ts covers both match and no-match paths with the correct structured shape.
  • register.test.ts adds the diff no-change path and verifies both structuredContent and content in one assertion.
  • screenshot-tool.test.ts cleanly extends existing assertions and changes the "no annotations" test from toBeUndefined() to the full expected shape.

The grep.test.ts helper sessionWithSnapshot is minimal and correct. One thought: there's no test for the over: 'content' path with structured output, but that requires a real CDP Runtime session — the structured output logic is format-agnostic anyway, so unit coverage here is fine.

Summary

Area Verdict
screenshot.ts ✅ Correct — minor perf nit on Buffer.from allocation
grep.ts ✅ Correct — design question on raw content in structured vs. wrapping
diff-format.ts ✅ Clean
Tests ✅ Solid coverage
Backward compat ✅ Purely additive

Good PR. The two items above are nits/design questions, not blockers.

@github-actions

Copy link
Copy Markdown
Contributor

✅ Tests passed — 1419/1423

Suite Passed Failed Skipped
agent 272/272 0 0
build 37/37 0 0
⚠️ claw-app 0/0 0 0
⚠️ claw-server 0/0 0 0
eval 91/91 0 0
server-agent 301/301 0 0
server-api 143/143 0 0
server-browser 10/10 0 0
server-integration 10/10 0 0
server-lib 256/257 0 1
server-root 47/50 0 3
server-tools 252/252 0 0

View workflow run

@shadowfax92 Nikhil (shadowfax92) merged commit b464fc1 into main Jun 24, 2026
21 checks passed
@greptile-apps

greptile-apps Bot commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR completes structured output coverage for the three remaining browser-mcp tools — screenshot, grep, and diff — so every tool now emits structuredContent on every success path, enabling structured-only MCP clients to be fully self-sufficient.

  • screenshot: Always emits { page, format, bytes, image, annotations? } (previously emitted only { page, annotations } and only when annotations existed).
  • grep: Both match and no-match paths now emit { page, over, count, matches } (previously only { count }); however, matches contains raw page content without the wrapUntrusted fencing that protects the text path against indirect prompt injection.
  • diff: Adds a changed boolean discriminator — { changed: false } on the no-change path and changed: true on the change path — cleanly filling the last gap without touching the existing change-path structured output.

Confidence Score: 3/5

The screenshot and diff changes are safe; grep's new structuredContent.matches exposes raw page text to LLM clients without the injection guard that the text path already has.

The grep change puts unguarded page content into structuredContent.matches while the text path wraps the same data with wrapUntrusted. BrowserOS explicitly drives real logged-in sessions where scraped page text is an indirect-prompt-injection vector; diff-format.ts already sets the precedent of fencing content in structured output too. An LLM-driven MCP client consuming structuredContent.matches would receive raw page text with no injection-prevention markers.

packages/browseros-agent/packages/browser-mcp/src/tools/grep.ts — the matches array in structuredContent needs the same trust-boundary treatment as the text path.

Security Review

  • Indirect prompt injection (packages/browseros-agent/packages/browser-mcp/src/tools/grep.ts, structuredContent.matches): raw page accessibility-tree or visible-text content is returned in the structured output without wrapUntrusted fencing, exposing LLM-driven MCP clients to hostile instructions embedded in page content.

Important Files Changed

Filename Overview
packages/browseros-agent/packages/browser-mcp/src/tools/grep.ts Adds page, over, count, and matches fields to structuredContent on both match and no-match paths; raw page content in matches is not wrapped with wrapUntrusted, unlike the text path and unlike diff-format.ts's structured output.
packages/browseros-agent/packages/browser-mcp/src/tools/screenshot.ts structuredContent is now always emitted with page, format, bytes, image, and optional annotations; bytes is computed via a full Buffer allocation (minor inefficiency).
packages/browseros-agent/packages/browser-mcp/src/tools/diff-format.ts Adds changed:false discriminator for the no-change path and changed:true for the change path; clean, additive, and consistent with existing wrapUntrusted usage in structured output.
packages/browseros-agent/apps/server/tests/tools/browser/grep.test.ts New test file covering both match and no-match structured output for grep; thorough and well-structured.
packages/browseros-agent/apps/server/tests/tools/browser/register.test.ts Updates existing screenshot tests to assert new structuredContent fields and adds a diff no-change assertion; all changes are correct and well-targeted.
packages/browseros-agent/apps/server/tests/tools/browser/screenshot-tool.test.ts Extends existing screenshot unit tests to assert format, bytes, and image in structuredContent for both annotated and unannotated cases.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[MCP Tool Call] --> B{Tool}
    B --> C[screenshot]
    B --> D[grep]
    B --> E[diff]
    C --> C1[Capture image result.data base64]
    C1 --> C2["structuredContent: { page, format, bytes, image, annotations? }"]
    D --> D1{matches.length > 0?}
    D1 -->|yes| D2["text: wrapUntrusted fenced\nstructuredContent: { page, over, count, matches }"]
    D1 -->|no| D3["text: 'no matches'\nstructuredContent: { page, over, count:0, matches:[] }"]
    D2 --> D4["⚠️ matches NOT wrapUntrusted-fenced"]
    E --> E1{diff.changed?}
    E1 -->|no| E2["structuredContent: { changed: false }"]
    E1 -->|yes| E3["structuredContent: { changed: true, diff: wrappedDiff }"]
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A[MCP Tool Call] --> B{Tool}
    B --> C[screenshot]
    B --> D[grep]
    B --> E[diff]
    C --> C1[Capture image result.data base64]
    C1 --> C2["structuredContent: { page, format, bytes, image, annotations? }"]
    D --> D1{matches.length > 0?}
    D1 -->|yes| D2["text: wrapUntrusted fenced\nstructuredContent: { page, over, count, matches }"]
    D1 -->|no| D3["text: 'no matches'\nstructuredContent: { page, over, count:0, matches:[] }"]
    D2 --> D4["⚠️ matches NOT wrapUntrusted-fenced"]
    E --> E1{diff.changed?}
    E1 -->|no| E2["structuredContent: { changed: false }"]
    E1 -->|yes| E3["structuredContent: { changed: true, diff: wrappedDiff }"]
Loading
Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
packages/browseros-agent/packages/browser-mcp/src/tools/grep.ts:52-57
**Raw page content in `structuredContent.matches` bypasses the trust boundary**

The text path correctly fences matched lines with `wrapUntrusted(matches.join('\n'), origin)`, but the new `structuredContent.matches` array contains the same lines **unwrapped**. `diff-format.ts` sets the precedent: it puts `wrappedDiff` (the `wrapUntrusted`-fenced string) into its structured content too. An LLM-driven MCP client reading `structuredContent.matches` will receive raw page text with no indirect-prompt-injection markers; since BrowserOS drives real logged-in sessions, a hostile page can embed arbitrary instructions in its accessibility tree that flow directly to the model through this path.

### Issue 2 of 2
packages/browseros-agent/packages/browser-mcp/src/tools/screenshot.ts:92
`Buffer.from(..., 'base64').length` allocates an unnecessary buffer just to measure decoded byte length. Screenshots can be hundreds of KB to several MB, making this a non-trivial allocation on every call. The byte count can be derived directly from the base64 string length in O(1) without any allocation.

```suggestion
        bytes: Math.floor((result.data.replace(/=+$/, '').length * 3) / 4),
```

Reviews (1): Last reviewed commit: "feat(browser-mcp): structured results fo..." | Re-trigger Greptile

Comment on lines 52 to 57
return textResult(wrapUntrusted(matches.join('\n'), origin), {
page: args.page,
over: args.over,
count: matches.length,
matches,
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 security Raw page content in structuredContent.matches bypasses the trust boundary

The text path correctly fences matched lines with wrapUntrusted(matches.join('\n'), origin), but the new structuredContent.matches array contains the same lines unwrapped. diff-format.ts sets the precedent: it puts wrappedDiff (the wrapUntrusted-fenced string) into its structured content too. An LLM-driven MCP client reading structuredContent.matches will receive raw page text with no indirect-prompt-injection markers; since BrowserOS drives real logged-in sessions, a hostile page can embed arbitrary instructions in its accessibility tree that flow directly to the model through this path.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/browseros-agent/packages/browser-mcp/src/tools/grep.ts
Line: 52-57

Comment:
**Raw page content in `structuredContent.matches` bypasses the trust boundary**

The text path correctly fences matched lines with `wrapUntrusted(matches.join('\n'), origin)`, but the new `structuredContent.matches` array contains the same lines **unwrapped**. `diff-format.ts` sets the precedent: it puts `wrappedDiff` (the `wrapUntrusted`-fenced string) into its structured content too. An LLM-driven MCP client reading `structuredContent.matches` will receive raw page text with no indirect-prompt-injection markers; since BrowserOS drives real logged-in sessions, a hostile page can embed arbitrary instructions in its accessibility tree that flow directly to the model through this path.

How can I resolve this? If you propose a fix, please make it concise.

@greptile-apps

greptile-apps Bot commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR completes structured output coverage for three browser-mcp tools: screenshot now always emits structuredContent with page, format, bytes, and image; grep emits page, over, count, and matches on both match and no-match paths; diff gains a changed boolean discriminator so the no-change path is no longer structureless.

  • screenshot: structuredContent is now always present (previously only when annotations existed), with the decoded byte length and raw base64 image string included to support structured-only MCP clients.
  • grep: both branches return a consistent { page, over, count, matches } shape; the text output's wrapUntrusted fence is preserved but not applied to the matches array in structuredContent.
  • diff: { changed: false } is returned on the no-change path; all changed-path variants gain changed: true as a discriminator field.

Confidence Score: 3/5

Safe to merge for diff and screenshot; the grep change exposes raw page content in structuredContent without the prompt-injection fence that protects the text output path.

The diff and screenshot changes are clean and additive with good test coverage. The grep change puts raw page-derived text into structuredContent.matches while the codebase explicitly wraps that same content with wrapUntrusted on the text path to guard against indirect prompt injection. BrowserOS drives real logged-in sessions, so page content is treated as a live injection vector throughout the codebase — skipping the fence on the structured path is a meaningful gap, not a theoretical one.

packages/browseros-agent/packages/browser-mcp/src/tools/grep.ts — the matches array in structuredContent carries raw page content without the trust-boundary fence.

Security Review

  • Indirect prompt injection via grep structuredContent (grep.ts): The matches array in structuredContent is raw page-derived text. The text output path correctly wraps identical content with wrapUntrusted (nonce-fenced markers that instruct LLMs to treat the content as data). MCP clients — including LLM-backed ones — that consume structuredContent.matches directly receive the content without that protection, so a hostile page can embed instruction-like text in button labels or link text and have it delivered unwrapped.

Important Files Changed

Filename Overview
packages/browseros-agent/packages/browser-mcp/src/tools/grep.ts Adds page, over, count, and matches to structuredContent on both match and no-match paths; matches array contains raw page content that bypasses the wrapUntrusted trust boundary applied to the text output.
packages/browseros-agent/packages/browser-mcp/src/tools/screenshot.ts Always emits structuredContent with page, format, bytes, image, and optional annotations; uses a redundant Buffer.from decode to compute bytes when arithmetic on the base64 length suffices.
packages/browseros-agent/packages/browser-mcp/src/tools/diff-format.ts Adds changed: false discriminator to the no-change path and changed: true to all existing change paths — clean, additive, no regressions.
packages/browseros-agent/apps/server/tests/tools/browser/grep.test.ts New test file covering both the match and no-match structured output paths for grep; clear and well-scoped.
packages/browseros-agent/apps/server/tests/tools/browser/register.test.ts Extends existing screenshot and diff tests to assert the new structuredContent shapes; new diff no-change assertion is correctly isolated.
packages/browseros-agent/apps/server/tests/tools/browser/screenshot-tool.test.ts Updates existing screenshot test assertions to expect the new format, bytes, and image fields in structuredContent; changes are accurate.
Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
packages/browseros-agent/packages/browser-mcp/src/tools/grep.ts:52-57
**`matches` in structuredContent bypasses the trust boundary**

The `matches` array is raw page content placed directly into `structuredContent`, but the text output correctly wraps the same lines with `wrapUntrusted`. Any MCP client (including an LLM-backed one) that consumes `structuredContent.matches` directly will receive page-derived text without the nonce-fenced untrusted-content marker, leaving it exposed to indirect prompt injection. A hostile page could embed instruction-like text in button labels or link text that appears verbatim in `matches`.

### Issue 2 of 2
packages/browseros-agent/packages/browser-mcp/src/tools/screenshot.ts:92
`Buffer.from(result.data, 'base64')` decodes the entire image into a new heap buffer just to read `.length`. For a 200 KB screenshot this allocates ~200 KB of memory that is immediately discarded. The byte length can be computed arithmetically from the base64 string itself.

```suggestion
        bytes:
          Math.floor(result.data.length * 3 / 4) -
          (result.data.endsWith('==') ? 2 : result.data.endsWith('=') ? 1 : 0),
```

Reviews (2): Last reviewed commit: "feat(browser-mcp): structured results fo..." | Re-trigger Greptile

Comment on lines 52 to 57
return textResult(wrapUntrusted(matches.join('\n'), origin), {
page: args.page,
over: args.over,
count: matches.length,
matches,
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 security matches in structuredContent bypasses the trust boundary

The matches array is raw page content placed directly into structuredContent, but the text output correctly wraps the same lines with wrapUntrusted. Any MCP client (including an LLM-backed one) that consumes structuredContent.matches directly will receive page-derived text without the nonce-fenced untrusted-content marker, leaving it exposed to indirect prompt injection. A hostile page could embed instruction-like text in button labels or link text that appears verbatim in matches.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/browseros-agent/packages/browser-mcp/src/tools/grep.ts
Line: 52-57

Comment:
**`matches` in structuredContent bypasses the trust boundary**

The `matches` array is raw page content placed directly into `structuredContent`, but the text output correctly wraps the same lines with `wrapUntrusted`. Any MCP client (including an LLM-backed one) that consumes `structuredContent.matches` directly will receive page-derived text without the nonce-fenced untrusted-content marker, leaving it exposed to indirect prompt injection. A hostile page could embed instruction-like text in button labels or link text that appears verbatim in `matches`.

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

structuredContent: {
page: args.page,
format: args.format,
bytes: Buffer.from(result.data, 'base64').length,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Buffer.from(result.data, 'base64') decodes the entire image into a new heap buffer just to read .length. For a 200 KB screenshot this allocates ~200 KB of memory that is immediately discarded. The byte length can be computed arithmetically from the base64 string itself.

Suggested change
bytes: Buffer.from(result.data, 'base64').length,
bytes:
Math.floor(result.data.length * 3 / 4) -
(result.data.endsWith('==') ? 2 : result.data.endsWith('=') ? 1 : 0),
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/browseros-agent/packages/browser-mcp/src/tools/screenshot.ts
Line: 92

Comment:
`Buffer.from(result.data, 'base64')` decodes the entire image into a new heap buffer just to read `.length`. For a 200 KB screenshot this allocates ~200 KB of memory that is immediately discarded. The byte length can be computed arithmetically from the base64 string itself.

```suggestion
        bytes:
          Math.floor(result.data.length * 3 / 4) -
          (result.data.endsWith('==') ? 2 : result.data.endsWith('=') ? 1 : 0),
```

How can I resolve this? If you propose a fix, please make it concise.

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant