GOO-427 Download Fix
GOO-427 · Goodtake MCP gallery · tested 2026-10-25

Download button now sends the file claude.ai can save

claude.ai's ui/download-file keeps only embedded type: "resource" entries and silently refuses a resource_link. The fixed widget (v7) fetches the image and sends its bytes. In a stand-in host that runs claude.ai's own request-mapping code, the bytes it would save are identical to the source file. Production needs bucket CORS before it works there.

6 of 6 cases pass · confirmed in real claude.ai

Live check on 2026-10-25: a local gateway running the fix, behind a tunnel and connected to claude.ai as a custom connector, generated a dog image. Clicking Download opened claude.ai's own "Download file" dialog naming goodtake-9aa4ebed-1.png, and the file saved. The dialog belongs to claude.ai and appears for every app download; no widget or server setting skips it.

Recording: the happy path

Case 1, 9.6 s. Hover, click Download, and the stand-in host logs the ui/download-file message. claude.ai's own M() turns it into goodtake-de5eed00-1.png with the source file's sha256.

What was proven, hop by hop

ProvenClick in widgetReal Playwright click on the pill inside a cross-origin sandboxed iframe
ProvenFetch from GCSDev bucket with CORS, under the widget CSP with connect-src
ProvenRequest shapeOne type: "resource" entry, base64 blob, file:/// uri
Provenclaude.ai handlerReal claude.ai accepted the request and opened its Download file dialog
ProvenSave dialog in claude.aiClicked by ziann on 2026-10-25: goodtake-9aa4ebed-1.png saved

Cases

#SetupExpectedSeenResult
1v7 widget, v7 CSP, dev bucketOne save request with the real bytesM() gives 1 file, goodtake-de5eed00-1.png; bytes sha256 match the sourcePASS
2v7 widget, prod bucket (no CORS today)Fetch blocked, one warning, no requestBrowser CORS error, then gallery: download failed …: Failed to fetchPASS
3v7 widget, old CSP (no connect-src)Fetch blocked by CSP"violates … connect-src", then the warning; no requestPASS
4v6 widget (before the fix)Reproduce the bugSends resource_link; M() gives []; logs "cancelled or denied by host"PASS
5v7 widget, user cancelsNo second promptExactly one ui/download-file, no ui/open-linkPASS
6v7 widget, host without downloadFileUnchanged link routeOne ui/open-link to the attachment download_urlPASS
The gallery widget in the stand-in host showing the dog image with the Download pill at bottom left
Case 1, after the click: the v7 widget in the stand-in host, Download pill at bottom left.

Bytes saved, measured

Source file
PNG 1024 × 768, 1,489,579 bytes
Blob sent
1,986,108 base64 chars (4 × ⌈1,489,579 / 3⌉)
sha256 source
603ce521…bb706f
sha256 decoded
603ce521…bb706f
Saved name
goodtake-de5eed00-1.png
MIME
image/png

Evidence, verbatim

Case 1, the request the v7 widget sent (blob shortened):

{"method": "ui/download-file", "params": {"contents": [{"type": "resource", "resource": {
  "uri": "file:///goodtake-de5eed00-1.png", "mimeType": "image/png",
  "blob": "<1986108 chars>"}}]}}

Case 2, the production bucket today (the widget's own line):

gallery: download failed (a failed fetch means connectDomains or bucket CORS): Failed to fetch

Case 4, the old widget. This is the line seen in claude.ai on 2026-10-23:

gallery: download cancelled or denied by host

Before production works

Someone with Storage Admin on the production GCP project applies the repo's CORS file to the production bucket, then deploys:

gcloud storage buckets update gs://goodtake-ai-prod --cors-file=cors.json

Without it, case 2 is what users get: a click that only logs a warning.

Not covered