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.
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.
ui/download-file message. claude.ai's own M() turns it into goodtake-de5eed00-1.png with the source file's sha256.type: "resource" entry, base64 blob, file:/// urigoodtake-9aa4ebed-1.png saved| # | Setup | Expected | Seen | Result |
|---|---|---|---|---|
| 1 | v7 widget, v7 CSP, dev bucket | One save request with the real bytes | M() gives 1 file, goodtake-de5eed00-1.png; bytes sha256 match the source | PASS |
| 2 | v7 widget, prod bucket (no CORS today) | Fetch blocked, one warning, no request | Browser CORS error, then gallery: download failed …: Failed to fetch | PASS |
| 3 | v7 widget, old CSP (no connect-src) | Fetch blocked by CSP | "violates … connect-src", then the warning; no request | PASS |
| 4 | v6 widget (before the fix) | Reproduce the bug | Sends resource_link; M() gives []; logs "cancelled or denied by host" | PASS |
| 5 | v7 widget, user cancels | No second prompt | Exactly one ui/download-file, no ui/open-link | PASS |
| 6 | v7 widget, host without downloadFile | Unchanged link route | One ui/open-link to the attachment download_url | PASS |
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
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.
go test ./routes/mcp/... (embed and CSP tests) only, because port 8080 belongs to another worktree.goo-427-test/goo427-dog.png, in the dev bucket.