File Downloads in MCP Apps: Export CSV, JSON, PDFs, and Binary Files

MCP Apps can export text, binary files, and server-hosted reports through a host-mediated download request.
File export looks simple in a normal web app. Build a Blob, create an object URL, click an anchor with download, and revoke the URL.
That browser pattern is unreliable inside an MCP App. The view runs in a host-controlled sandboxed iframe, so direct downloads, popups, and navigation may be blocked. MCP Apps solve this with a host-mediated request called ui/download-file.
TL;DR: Send downloads through the host with App.downloadFile. Use embedded text for small CSV, JSON, Markdown, or text exports; use embedded base64 for small binary files; and use resource_link for large or server-generated files. Check getHostCapabilities()?.downloadFile, treat denial as a normal user state, and keep a server-side fallback for hosts that do not support downloads.
Why MCP App Downloads Need a Host API
An MCP App has three relevant security boundaries:
- The app view runs inside an iframe.
- The host controls what that iframe may do.
- The MCP server owns protected data and backend operations.
The view cannot assume it has the same browser privileges as a first-party page. A plain anchor can fail for several reasons:
- The iframe sandbox does not allow downloads.
- The host intercepts or blocks navigation.
- A popup requires a user gesture the bridge cannot preserve.
- The browser ignores the
downloadattribute for a cross-origin URL. - The host needs to ask the user before saving a file.
The official App.downloadFile API sends a JSON-RPC request from the view to the host:
MCP App view
-> ui/download-file
Host
-> optional confirmation
-> reads embedded content or fetches a resource link
-> saves the file
The host decides whether to approve the request and how to present the save flow. That keeps the iframe isolated while still supporting useful export buttons.
The Download Request Shape
The low-level SDK accepts a contents array with standard MCP resource content:
const result = await app.downloadFile({
contents: [
{
type: 'resource',
resource: {
uri: 'file:///export.json',
mimeType: 'application/json',
text: JSON.stringify(data, null, 2),
},
},
],
});
if (result.isError) {
showMessage('The download was cancelled or denied.');
}
There are three useful forms.
| Download form | Payload | Best for |
|---|---|---|
| Embedded text resource | resource.text | JSON, CSV, Markdown, logs, config files |
| Embedded binary resource | resource.blob as base64 | Small images, PDFs, audio, or archives already in memory |
| Resource link | type: "resource_link" and uri | Large files, generated reports, stored files, signed URLs |
The URI and MIME type help the host identify the file. Use a descriptive file:/// URI for embedded content and a real HTTPS URL for a resource link. The host still controls the final filename and save experience.
Check Download Support Before Showing the Button
File downloads are an optional host capability. Check after the MCP App connection finishes:
const capabilities = app.getHostCapabilities();
const canDownload = Boolean(capabilities?.downloadFile);
downloadButton.hidden = !canDownload;
Do not infer support from a host name. ChatGPT, Claude, desktop apps, web apps, mobile apps, and enterprise configurations can expose different capabilities. The current runtime signal is more useful than a hard-coded host matrix.
If downloads are central to the workflow, replace the hidden button with a fallback:
- Call an app-only server tool that creates the export and returns a resource link.
- Ask the host to open a short-lived export URL when open-link support exists.
- Save the export to the user’s connected storage.
- Email the export after explicit confirmation.
- Show a copyable text representation for small exports.
The fallback should preserve the user’s work. A disabled button with no explanation leaves them stuck.
Export JSON as Embedded Text
JSON is the easiest portable export because the app already has structured data:
async function exportJson(app: App, value: unknown) {
const text = JSON.stringify(value, null, 2);
const result = await app.downloadFile({
contents: [
{
type: 'resource',
resource: {
uri: 'file:///account-audit.json',
mimeType: 'application/json',
text,
},
},
],
});
if (result.isError) {
return { ok: false, message: 'Download cancelled.' };
}
return { ok: true };
}
Keep the export data separate from model-visible content and structuredContent. The view can generate a file from data it already received, but you should not add a large export to the model’s context just to make it downloadable.
For private or high-volume data, generate the export on the server instead. That keeps authorization and filtering near the data source and avoids holding the whole file in the iframe.
Export CSV Without Spreadsheet Injection
CSV needs more care than joining fields with commas. Values can contain commas, quotes, and newlines. Cells beginning with =, +, -, @, tabs, or carriage returns can also be interpreted as formulas by spreadsheet software.
This serializer quotes every field and prefixes formula-like values with an apostrophe:
function escapeCsvCell(value: unknown): string {
const text = String(value ?? '');
const formulaSafe = /^[=+\-@\t\r]/.test(text) ? `'${text}` : text;
return `"${formulaSafe.replaceAll('"', '""')}"`;
}
function toCsv(rows: Record<string, unknown>[]): string {
if (rows.length === 0) return '';
const columns = Object.keys(rows[0]);
const header = columns.map(escapeCsvCell).join(',');
const body = rows.map((row) => columns.map((column) => escapeCsvCell(row[column])).join(','));
return [header, ...body].join('\r\n');
}
Then send the CSV as an embedded text resource:
const csv = toCsv(rows);
await app.downloadFile({
contents: [
{
type: 'resource',
resource: {
uri: 'file:///transactions.csv',
mimeType: 'text/csv;charset=utf-8',
text: csv,
},
},
],
});
Use a fixed column list when the schema is known. Taking columns from the first row is convenient for a small example, but production exports should define column order and omit private fields explicitly.
Download Binary Files with Base64
An embedded binary resource uses blob, which contains base64 text:
await app.downloadFile({
contents: [
{
type: 'resource',
resource: {
uri: 'file:///chart.png',
mimeType: 'image/png',
blob: base64EncodedPng,
},
},
],
});
If the app starts with an ArrayBuffer, convert it in chunks so a large spread does not overflow the JavaScript call stack:
function bytesToBase64(bytes: Uint8Array): string {
let binary = '';
const chunkSize = 0x8000;
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
const chunk = bytes.subarray(offset, offset + chunkSize);
binary += String.fromCharCode(...chunk);
}
return btoa(binary);
}
Base64 adds roughly one third to the byte size, and the app, bridge, and host may hold more than one copy during serialization. That makes embedded blobs a poor fit for large PDFs, videos, or archives.
Use a resource link when:
- The file is already stored on a server.
- The file is large enough to cause visible memory or serialization cost.
- A backend job generates the file.
- Access needs a short-lived authorization check.
- The user may retry the download without rebuilding the bytes.
Use a Resource Link for PDFs and Large Exports
A resource link asks the host to fetch the file:
const result = await app.downloadFile({
contents: [
{
type: 'resource_link',
uri: report.downloadUrl,
name: 'Q4 account report',
mimeType: 'application/pdf',
size: report.size,
},
],
});
The link should be reachable by the host and authorize only the intended file. A common pattern is:
- The app calls an app-only tool such as
create_report_export. - The server checks the current user and report access.
- The server generates or finds the file.
- The server returns a short-lived signed HTTPS URL in view-only metadata.
- The app passes that URL to
downloadFileas a resource link.
Keep signed URLs out of model-visible content and structuredContent when the model does not need them. Put them in result _meta, which the app can read without adding the secret-bearing URL to the conversation context.
Short expiry is useful, but do not make it so short that the URL expires before the host finishes its confirmation flow. Bind the URL to the file and user, allow only GET, and set a reasonable size limit on the generated export.
Generate the File in the View or on the Server?
Use the view for small, deterministic transformations of data already present:
- Convert a visible table to CSV.
- Save app settings as JSON.
- Export a short Markdown summary.
- Download a small image the app already rendered.
Use the server when the export needs:
- Fresh data from a protected API.
- More rows than the view received.
- A PDF renderer, archive builder, or media encoder.
- Audit logging.
- Row-level authorization.
- Long-running job status and retry support.
The server should return metadata, not the whole binary file in structuredContent. A result might look like this:
return {
content: [{ type: 'text', text: 'The account report is ready to download.' }],
structuredContent: {
reportId,
fileName: 'account-report.pdf',
mimeType: 'application/pdf',
size,
},
_meta: {
downloadUrl: signedUrl,
expiresAt,
},
};
The model sees that the report exists. The view gets the temporary URL. The host downloads the file.
Handle Denial, Cancellation, and Transport Errors Separately
downloadFile can return isError: true when the host denies the request or the user cancels. It can also reject when the bridge times out or disconnects.
Treat those as separate states:
async function requestDownload(app: App, contents: Parameters<App['downloadFile']>[0]['contents']) {
try {
const result = await app.downloadFile({ contents });
if (result.isError) {
return {
status: 'not-downloaded',
message: 'The download was cancelled or blocked by the host.',
};
}
return {
status: 'requested',
message: 'Your download was sent to the host.',
};
} catch {
return {
status: 'failed',
message: 'The app lost its connection before the download started. Try again.',
};
}
}
Do not show a red error when the user closes a confirmation dialog. Cancellation is a normal decision. A connection failure needs a retry path, while a policy denial may need a different export route.
Also avoid claiming the file was saved. A successful bridge response means the host accepted the request, but the browser or operating system may still control the final save.
Build a React Export Button with sunpeak
sunpeak wraps the MCP Apps method with useDownloadFile:
import { useState } from 'react';
import { useDownloadFile } from 'sunpeak';
type ExportStatus = 'idle' | 'requesting' | 'requested' | 'not-downloaded' | 'failed';
export function JsonExportButton({ data }: { data: unknown }) {
const downloadFile = useDownloadFile();
const [status, setStatus] = useState<ExportStatus>('idle');
async function onExport() {
setStatus('requesting');
try {
const result = await downloadFile({
contents: [
{
type: 'resource',
resource: {
uri: 'file:///export.json',
mimeType: 'application/json',
text: JSON.stringify(data, null, 2),
},
},
],
});
setStatus(result?.isError ? 'not-downloaded' : 'requested');
} catch {
setStatus('failed');
}
}
return (
<div>
<button type="button" onClick={onExport} disabled={status === 'requesting'}>
{status === 'requesting' ? 'Preparing download...' : 'Export JSON'}
</button>
<p role="status" aria-live="polite">
{status === 'requested' && 'Download requested.'}
{status === 'not-downloaded' && 'Download cancelled or denied.'}
{status === 'failed' && 'Could not reach the host. Try again.'}
</p>
</div>
);
}
The direct MCP Apps SDK works with React, Vue, Svelte, or plain JavaScript. The important contract is the same: resource content goes to the host, and the host owns the download.
Security Checks for Download Endpoints
A download endpoint is a data access endpoint. Apply the same rules you would use for an API:
- Authorize the current user against the requested file or export.
- Generate file IDs on the server instead of trusting file paths from the view.
- Keep signed URLs short-lived and limited to one file and method.
- Set an explicit MIME type and safe
Content-Dispositionfilename. - Strip path separators and control characters from suggested filenames.
- Limit export row counts and binary size.
- Log file ID, user ID, result, and size without logging the file contents or signed URL.
- Neutralize formula-like cells in CSV exports.
- Avoid secrets, internal fields, and raw debug data in exports.
Do not pass a user-controlled URL directly into resource_link. If the app can download arbitrary URLs, it can turn the host into a fetch proxy. Generate or allowlist resource links on the server.
Test the Download Contract in Layers
A live-host click test alone is slow and hard to diagnose. Split the checks by boundary.
Unit test the view request
Mock the download function and assert the exact resource shape:
const downloadFile = vi.fn().mockResolvedValue({});
await exportTransactions(downloadFile, [{ id: 'txn_1', amount: 42 }]);
expect(downloadFile).toHaveBeenCalledWith({
contents: [
{
type: 'resource',
resource: expect.objectContaining({
uri: 'file:///transactions.csv',
mimeType: 'text/csv;charset=utf-8',
}),
},
],
});
Add cases for:
- Missing download capability.
isError: true.- Rejected bridge promise.
- Empty exports.
- Quotes, commas, newlines, and formula-like CSV cells.
- Correct base64 bytes and MIME type.
- Repeated clicks while a request is pending.
Test the server export
For resource links, test:
- Unauthorized users cannot create or fetch the export.
- The URL expires.
- The URL cannot select a different file by changing a path or ID.
Content-Type,Content-Length, andContent-Dispositionare correct.- Large exports stop at the documented limit.
- Retrying does not create duplicate expensive jobs when the operation is idempotent.
Test the host flow
Use simulation fixtures to pin export-ready, export-empty, export-denied, expired-link, and unsupported-host states. Then run E2E tests in a local MCP App inspector so the app renders inside replicated host runtimes.
Keep one small live-host smoke test per supported host. It should verify that the export control appears, the host accepts the request, and the confirmation or save flow starts. Do not make every CI run depend on writing a real file to a developer’s Downloads folder.
Common Download Bugs
Using an anchor as the only path
It works in local browser development but fails in the sandbox. Use ui/download-file and test the missing-capability fallback.
Putting binary bytes in structuredContent
That expands model-facing payloads and can expose data the model does not need. Use an embedded resource, resource link, or view-only _meta.
Embedding a large base64 file
The app pays the base64 expansion and JSON serialization cost. Generate a server resource link instead.
Treating isError as an exception
Denial and user cancellation can arrive as a normal result with isError: true. Handle the result and rejected promises.
Exposing signed URLs to the model
The model does not need a temporary credential. Return safe file metadata in structuredContent and keep the URL in _meta.
Trusting the requested filename
Sanitize names and generate server paths independently. A filename is display metadata, not a filesystem location.
Assuming every host supports the same flow
Feature-detect downloadFile, test each fallback, and keep the export tool useful in non-UI MCP clients.
Shipping Checklist
- The app checks the host’s
downloadFilecapability. - Small text exports use embedded resources.
- Large or server-generated files use authorized resource links.
- Binary blobs have valid base64 and an explicit MIME type.
- CSV fields are escaped and formula-like cells are neutralized.
- Signed URLs stay out of model-visible content.
- The UI handles accepted, cancelled, denied, disconnected, and unsupported states.
- Download endpoints enforce authorization, size limits, safe filenames, and expiry.
- Unit, server, inspector, and narrow live-host tests cover the flow.
Build and Test the Export Across Hosts
You can implement ui/download-file directly with the MCP Apps SDK. The harder part is proving the same export behaves well across host sandboxes, denied requests, file sizes, and fallback states.
sunpeak gives you the useDownloadFile hook, replicated ChatGPT and Claude runtimes, deterministic simulations, and automated tests. You can exercise the download states locally and in CI without regenerating a real report or spending host credits on every code change.
Create a project with:
npx sunpeak newGet Started
npx sunpeak newFurther Reading
- Testing file handling in MCP Apps - uploads, downloads, and cross-host behavior
- File uploads in MCP Apps - ChatGPT file APIs and portable patterns
- MCP App capability detection - host features and fallbacks
- MCP App resource links, ui:// URIs, and MIME types
- MCP App CSP domains - connectDomains, resourceDomains, and frameDomains
- MCP App error handling - denied, cancelled, and failed states
- MCP App framework
- ChatGPT App framework
- Claude Connector framework
- sunpeak useDownloadFile hook reference
- sunpeak simulation fixtures for deterministic app states
- Official MCP Apps App.downloadFile API
- Official ui/download-file request format
Frequently Asked Questions
How do I download a file from an MCP App?
Call App.downloadFile with a contents array. Each item can be an embedded resource containing text or a base64 blob, or a resource_link that the host fetches. The SDK sends ui/download-file to the host, which controls the user confirmation and final download. Check the host downloadFile capability first and handle isError or a rejected request.
Why does a normal download link fail inside an MCP App?
MCP App views run in sandboxed iframes. The host may block direct browser downloads, navigation, popups, or the download attribute, so an anchor that works in a normal web app may do nothing in ChatGPT, Claude, or another host. ui/download-file moves the download through the host instead of asking the iframe to bypass its sandbox.
Should an MCP App use an embedded resource or a resource link for downloads?
Use embedded text for small JSON, CSV, Markdown, and text exports that already exist in the view. Use an embedded base64 blob for small binary files already held by the view. Use a resource_link for large files, generated PDFs, archives, and files already stored on a server. Resource links avoid base64 overhead and large in-memory copies.
How do I export a CSV from a ChatGPT App?
Serialize the rows in the app, escape quotes and newlines, neutralize cells that spreadsheet programs could treat as formulas, then pass the CSV as an embedded text resource with a file URI and text/csv MIME type. Send it with App.downloadFile or sunpeak useDownloadFile, and handle denial, cancellation, and missing host support.
Can an MCP App download a PDF?
Yes. For a small PDF already available in the view, send its base64 bytes as an embedded resource with application/pdf. For most generated PDFs, return or request a short-lived authorized URL and pass it as a resource_link. The host fetches that link and handles the download.
How do I detect MCP App file download support?
After the app connects, call app.getHostCapabilities() and check the downloadFile field before rendering an export control. Host support varies, so provide a fallback such as an app-only export tool that returns a resource link, a host-mediated open-link action, or a server-side email or storage flow.
How should I test MCP App downloads?
Unit test the exact download request, CSV escaping, base64 conversion, missing-capability branch, isError result, and rejected promise. Add server tests for authorization, MIME type, file size, signed URL expiry, and content headers. Use an MCP App inspector for host-level E2E tests, then keep one live-host smoke test for the final confirmation and save flow.