Repository navigation
feat(fetch): return binary response bodies and headers for hosted file downloads - #461
Conversation
…e downloads Adds an opt-in binary mode to the `fetch` verb so a hosted MCP can get a file's bytes through the user's browser (#460). - protocol: `FetchInit.responseType: 'base64'`; the answer reuses `body` (standard padded base64) marked `bodyEncoding: 'base64'`, plus a relayed `headers` subset (content-type / content-length / content-disposition). Text-mode frames are byte-for-byte unchanged. The extension hello gains an unsigned, advisory `features` list (`fetch_base64`). Size refusals carry `code: 'response_too_large'` and a fixed wording that starts `response body too large:`, which every published server already files as `body_too_large`. `MAX_BINARY_RESPONSE_BYTES` = 16 MiB, derived so the worst-case sealed frame (~28.5 MiB) clears the hosted relay's 32 MiB WebSocket message limit and MAX_FRAME_BYTES; a test holds it there. - server: `requestBytes()` / `getBytes()` resolve `{ status, url, headers, bytes: Uint8Array }`. Refused locally with `FetchproxyExtensionUpdateRequiredError` when the extension hello does not list `fetch_base64`, and on any answer without the `bodyEncoding` marker (an older extension answering in text). The final URL is re-checked against the declared domains. `FetchproxyResponseTooLargeError` maps the size refusal. `bridgeHealth().session.features` reports the list. - cli: `fpx get|request <url> -o <file>` writes the bytes. - test-helpers: `requestBytes` / `getBytes` spies. - docs: PROTOCOL.md §Binary fetch, SECURITY.md §T-binary-fetch. Closes #460 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012aZHZ6pnrdzrECCUaQW3gY
|
✅ Auto-review verdict: pass — The opt-in binary fetch mode is additive and carefully built. Bytes are only decoded when the answer is marked as base64, the final URL is checked against the declared domains, the base64 is validated strictly, and the error types are covered by tests. I found no issues. Reviewed commit No issues found. What I checked Protocol (
Server (
CLI
API contract
No field or value was renamed or removed, and no input was renamed or made required. Tests cover all 256 byte values round-tripping, the headers, the final URL after a redirect, off-domain request and final-URL refusals, a refusal for an old extension (no hello flag, or an answer without the marker), typed size refusals, unchanged text frames, the worst-case frame fitting under both transport limits, and the CLI paths. I didn't run the test suite myself. Open question (not a finding) |
Adds an opt-in binary mode to
fetch, so a hosted MCP can read a file's bytes through the user's browser. It covers files on a signed, CORS-less host outside the MCP's egress allowlist. Additive inside protocol 4: no version bump and no new capability. Nothing a signature covers changes.Closes #460
Wire
FetchInit.responseType: 'base64'. When it is absent the request is text, and the frame is byte-for-byte unchanged.bodyholds standard padded base64, markedbodyEncoding: 'base64', plus a relayedheadersmap (content-type,content-length,content-disposition, lower-cased, each present only when the response had it).bodykeeps every published validator accepting the frame.features: ['fetch_base64'], with the same bounds and unknown-name tolerance asunavailableCapabilities.MAX_BINARY_RESPONSE_BYTES= 16 MiB. This is derived from transport limits rather than picked:HOSTED_RELAY_MESSAGE_BYTES) andMAX_FRAME_BYTES(42 MiB). A test holds both;code: 'response_too_large', with a fixed wording that startsresponse body too large:. Older servers already classify that prefix asbody_too_large.Server (
@fetchproxy/server)requestBytes(method, path, opts)/getBytes(path, opts)→{ status, url, headers, bytes: Uint8Array }. They share URL resolution and the declared-domain guard withrequest(), which is refactored onto the same helper with unchanged behaviour.inPageandviaTabare not accepted.FetchproxyExtensionUpdateRequiredErroris thrown in two cases:fetch_base64;bodyEncodingmarker. That is an old extension answering in text, and its text is never decoded as bytes.fetch()returnskind: 'extension_update_required'instead of throwing.FetchproxyResponseTooLargeError(.limitBytes,.declaredBytes) is built from the code or the wording. The envelope now carries the wirecode.bridgeHealth().session.featuresreports what the extension advertised.cli:fpx get|request <url> -o <file>.test-helpers:requestBytes/getBytesspies.Extension
The extension half is nullnet-app/contextmint-bridge: a new background
fetch-binary.tshandler and the hello flag. It depends on@fetchproxy/protocol@^3.7.0, i.e. this PR's release.Release order: merge this → release fetchproxy (3.7.0) → the extension PR's CI goes green → ship ContextMint Bridge. Until the extension ships,
requestBytes()refuses cleanly with the update hint.Tests
responseType,bodyEncoding, headers andfeatures; strict base64; the size wording round-trip; the worst-case frame fits both transport limits.expectStatusis honoured;npm run build,npm run typecheckandnpm testpass: 149 files, 2214 tests.🤖 Generated with Claude Code
https://claude.ai/code/session_012aZHZ6pnrdzrECCUaQW3gY