feat(hub): forward desktop bridge tool results instead of re-wrapping (E4) - #564
Merged
Merged
Conversation
… (E4)
A screenshot taken through the hub reached a remote agent as base64
prose. `routeTunnelInvoke` passed the desktop's reply through
`mcpResultJSON`, which renders it as JSON inside one text block — so
`{"content":[{"type":"image","data":"<base64 PNG>"}]}` arrived as text
the model cannot look at.
Reading the far end showed the defect was never image-specific.
`dispatchHubInvoke` answers a relayed call with the object `callTool`
returns, and every bridge tool returns an MCP tool result — 16
`textContent()` calls plus the literal image returns. The relay was
re-wrapping an already-conformant result on every call; the image case
is just where the extra layer destroys the payload rather than merely
quoting it.
So the relay now forwards a well-formed tool result verbatim — isError
and extra keys included, since "what a local caller sees" is the whole
point — and keeps the JSON wrapper for anything else.
The shape guard errs toward the wrapper on purpose: `content` must be a
non-empty array of objects each carrying a string `type`. That rejects
the legal-but-indistinguishable `{"content":[]}`, costing a reader one
layer of quoting; the other direction — an arbitrary object with an
empty list under that key, forwarded as a tool result — hands the agent
something malformed, and no discriminator separates the two.
Two things checked rather than assumed:
- The Go fixtures were describing a desktop that does not exist.
`okBrowserEnvelope` was being handed bare JSON (`{"tabs":...}`,
`{"clicked":true}`), a shape no bridge tool has ever returned, so the
tests could not have caught the double-wrap and would not have
noticed the fix. The read-routing test now asserts against the real
reply shape; `okBrowserToolResult` names the distinction for the
callers that still exercise the fallback deliberately.
- The rest of the chain forwards it. The host-runner gateway returns a
`tools/call` result verbatim (`gwIdentity` only stamps `_meta`), so
the image block survives desktop to hub to gateway to agent.
Four mutations checked, all fail: removing the passthrough, dropping
the shape guard, skipping the per-block type check, and accepting an
empty content array.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review finding on E4: the passthrough forwarded the desktop's tool result without `resultType`. The desktop stamps the additive 2026-07-28 fields only on its own HTTP leg (stampMcpResult runs in the JSON-RPC serving path, which the tunnel bypasses), so the relayed reply was the one hub result missing the stamp the tools/call wrapper's comment says every dispatch path carries (ADR-063 D3) — and a LOCAL caller does see `resultType`, so "what a local caller sees" was off by exactly that key. Spec-legal either way (a missing resultType reads as complete), which is why this is consistency, not breakage. `routeTunnelInvoke` now wraps the passthrough in mcpwire.StampResult; the headline image test asserts the stamp, mutation-checked (stamping "nope" fails it). The predicate itself still returns the map verbatim. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Vision-parity E4 (asymmetry #3). A screenshot taken through the hub reached a remote agent as base64 prose.
routeTunnelInvokepassed the desktop's reply throughmcpResultJSON, which renders it as JSON inside one text block — so{"content":[{"type":"image","data":"<base64 PNG>"}]}arrived as text no model can look at.It was never image-specific
Reading the far end widened the fix.
dispatchHubInvokeanswers a relayed call with the objectcallToolreturns, and every bridge tool returns an MCP tool result — 16textContent()calls plus the literal{content:[{type:'image',…}]}returns. The relay was re-wrapping an already-conformant result on every call; the image case is just where the extra layer destroys the payload instead of merely quoting it.The relay now forwards a well-formed tool result verbatim —
isErrorand extra keys included, since "what a local caller sees" is the point — and keeps the JSON wrapper for anything else. Local and relayed calls now return the same thing.The guard errs toward the wrapper, deliberately
contentmust be a non-empty array of objects each carrying a stringtype.That rejects
{"content":[]}, which is a legal MCP result — a false negative costing a reader one layer of quoting. The other direction would hand the agent something malformed, and nothing distinguishes "an empty tool result" from "an arbitrary object with an empty list under that key". Losing nothing beats forwarding garbage.Two things checked rather than assumed
okBrowserEnvelopewas being handed bare JSON ({"tabs":…},{"clicked":true}) — a shape no bridge tool has ever returned. So the existing tests could not have caught the double-wrap and would not have noticed the fix either. The read-routing test now asserts against the real reply shape, andokBrowserToolResultnames the distinction for the callers that still exercise the fallback on purpose.tools/callresult verbatim (gwIdentityonly stamps_metaserver info), so the image block survives desktop → hub → gateway → agent.Verification
go test ./...— exit 0, all 33 packages (server suite 290s); build + vet clean.browser_invoke(image block survives; text stops being double-wrapped; five non-tool-result shapes keep the wrapper) andui_screenshot(the path the asymmetry is named for), plus a 15-case table for the shape predicate including the{"content":[]}boundary it deliberately gets "wrong".lint-openapi(venv) andlint-desktop-tokensfrom the repo root. NUL + CJK scans clean.Note for the merge
Branched off
main, not off #563 (E3) — the code paths don't overlap. Both edit the plan's status line, so whichever lands second needs a one-line rebase there.🤖 Generated with Claude Code