Testing File Handling in MCP Apps: Uploads, Downloads, and Cross-Host Compatibility (July 2026)

Testing file uploads and downloads in MCP Apps across ChatGPT and Claude.
File handling is one of the easiest parts of an MCP App to under-test because it crosses four boundaries at once: the model calls a tool, the server returns file metadata or resource content, the host renders a sandboxed app resource, and the user may pick or download a file through a host-owned UI.
That is why file tests need more than a component test with a fake File object. You need to test the tool contract, the resource UI, the host capability branch, and the server-side file checks separately.
TL;DR: Keep the portable path on MCP tools and resources. Use ChatGPT file APIs only behind feature detection. Put file metadata in structuredContent, not raw bytes. Test uploads with mocked host APIs, server contract tests, sunpeak simulation files, and one small live-host check for native picker behavior. Test downloads with useDownloadFile, binary resource assertions, and temporary URL failure cases.
What Changed Since the First Version
The file handling story has become clearer in two ways.
First, MCP Apps are now documented as an official MCP extension. The shared app model is tools, resources, sandboxed iframes, and a host bridge. File handling should start from that shared model, which means you should design for hosts that can render an app but may not expose the same native file picker.
Second, OpenAI’s current plugin docs document three ChatGPT file helpers in the window.openai runtime:
uploadFile(file, { library?: boolean })uploads a user-selected file and returns afileId.selectFiles()opens the ChatGPT file library when it is available and returns authorized file metadata.getFileDownloadUrl({ fileId })returns a temporary URL for an authorized file reference.
OpenAI’s ChatGPT UI guide also recommends using shared MCP Apps behavior first, then layering ChatGPT extensions behind feature detection. That is the testing rule too: test the portable path by default, and test host extensions as optional branches.
Four File Flows to Test
Do not start by asking “does file upload work?” Name the direction first.
| Flow | Example | Test focus |
|---|---|---|
| Server returns binary content | Tool generates a PDF or image | MCP resource shape, MIME type, base64 validity, render/download UI |
| User picks a local file | User uploads a PDF from the app | Host capability check, file metadata, server validation, upload failure |
| User picks an existing host file | ChatGPT file library selection | selectFiles() feature detection, returned fileId, fallback path |
| App downloads or exports a file | User downloads a CSV from the resource | Host-mediated download result, filename, MIME type, cancelled or denied result |
Most production bugs come from mixing these flows. A fileId is not a URL. A native picker is not available everywhere. A model-visible tool result is not a file store. A filename is not a trusted path.
Keep the Tool Contract Small
For file workflows, structuredContent should describe the file. It should not carry large bytes.
Good structuredContent for a file result looks like this:
{
"file": {
"id": "file_abc123",
"name": "quarterly-report.pdf",
"mimeType": "application/pdf",
"size": 482193,
"status": "ready",
"summary": "Quarterly revenue report with 8 pages"
}
}
That gives the model and resource enough context to reason about the file. The bytes belong somewhere else:
- In an MCP resource with
blobandmimeType. - Behind a
resource_linkor short-lived signed URL. - In host storage referenced by a
fileId. - In your own storage after server-side validation.
Write a protocol-level test that locks this contract down:
import { test, expect } from 'sunpeak/test';
test('analyze-report returns file metadata without raw bytes', async ({ mcp }) => {
const result = await mcp.callTool('analyze-report', {
reportId: 'q2-2026',
});
expect(result.isError).toBeFalsy();
expect(result.structuredContent.file).toMatchObject({
name: 'quarterly-report.pdf',
mimeType: 'application/pdf',
status: 'ready',
});
const serialized = JSON.stringify(result.structuredContent);
expect(serialized).not.toContain('JVBER'); // PDF base64 prefix
expect(serialized.length).toBeLessThan(4000);
});
This catches the easiest mistake: stuffing base64 into a model-visible field because it works in one manual test.
Test Binary MCP Resources Directly
When your server returns binary content through MCP resources, test the bytes before you render a browser.
import { test, expect } from 'sunpeak/test';
test('export-chart returns a valid PNG resource', async ({ mcp }) => {
const result = await mcp.callTool('export-chart', {
series: [12, 18, 23, 31],
});
expect(result.isError).toBeFalsy();
const content = result.content.find((item) => item.type === 'resource');
expect(content).toBeDefined();
expect(content?.resource.mimeType).toBe('image/png');
const bytes = Buffer.from(content?.resource.blob ?? '', 'base64');
expect(bytes.length).toBeGreaterThan(100);
expect(bytes[0]).toBe(0x89);
expect(bytes[1]).toBe(0x50);
});
Add a PDF version if your app exports documents:
expect(bytes[0]).toBe(0x25); // %
expect(bytes[1]).toBe(0x50); // P
expect(bytes[2]).toBe(0x44); // D
expect(bytes[3]).toBe(0x46); // F
The browser test can then focus on user-visible behavior instead of discovering that your base64 encoder broke.
Unit Test ChatGPT File APIs as Optional Capabilities
ChatGPT file APIs are useful, but they are host extensions. Test them like optional capabilities, not like the baseline runtime.
Here is a simple upload component:
import { useState } from 'react';
import { SafeArea, useCallServerTool } from 'sunpeak';
import { isChatGPT, useUploadFile } from 'sunpeak/chatgpt';
export function ReportUpload() {
const [file, setFile] = useState<File | null>(null);
const [status, setStatus] = useState<'idle' | 'uploading' | 'ready' | 'error'>('idle');
const uploadFile = useUploadFile();
const callServerTool = useCallServerTool();
async function handleUpload() {
if (!file || !isChatGPT()) return;
setStatus('uploading');
try {
const uploaded = await uploadFile(file, { library: true });
await callServerTool('import-chatgpt-file', {
fileId: uploaded.fileId,
fileName: file.name,
mimeType: file.type,
size: file.size,
});
setStatus('ready');
} catch {
setStatus('error');
}
}
if (!isChatGPT()) {
return (
<SafeArea className="p-4">
<p>Upload the report in your connected document system, then import it here.</p>
</SafeArea>
);
}
return (
<SafeArea className="p-4">
<input
aria-label="Report file"
type="file"
accept="application/pdf,text/csv"
onChange={(event) => setFile(event.currentTarget.files?.[0] ?? null)}
/>
<button type="button" disabled={!file || status === 'uploading'} onClick={handleUpload}>
Upload report
</button>
{status === 'error' ? <p>Upload failed. Try a smaller PDF or CSV.</p> : null}
</SafeArea>
);
}
The unit tests should cover the ChatGPT path, fallback path, success path, and error path:
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ReportUpload } from '../src/resources/report-upload';
let mockIsChatGPT = vi.fn(() => true);
let mockUploadFile = vi.fn();
let mockCallServerTool = vi.fn();
vi.mock('sunpeak', () => ({
SafeArea: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
useCallServerTool: () => mockCallServerTool,
}));
vi.mock('sunpeak/chatgpt', () => ({
isChatGPT: () => mockIsChatGPT(),
useUploadFile: () => mockUploadFile,
}));
describe('ReportUpload', () => {
beforeEach(() => {
mockIsChatGPT = vi.fn(() => true);
mockUploadFile = vi.fn().mockResolvedValue({ fileId: 'file_abc123' });
mockCallServerTool = vi.fn().mockResolvedValue({ structuredContent: { status: 'ready' } });
});
test('uploads a selected file and passes only metadata to the server tool', async () => {
render(<ReportUpload />);
const file = new File(['id,total\n1,42'], 'report.csv', { type: 'text/csv' });
await userEvent.upload(screen.getByLabelText('Report file'), file);
await userEvent.click(screen.getByText('Upload report'));
expect(mockUploadFile).toHaveBeenCalledWith(file, { library: true });
expect(mockCallServerTool).toHaveBeenCalledWith('import-chatgpt-file', {
fileId: 'file_abc123',
fileName: 'report.csv',
mimeType: 'text/csv',
size: file.size,
});
});
test('renders a non-ChatGPT fallback instead of a broken file button', () => {
mockIsChatGPT.mockReturnValue(false);
render(<ReportUpload />);
expect(screen.queryByText('Upload report')).toBeNull();
expect(screen.getByText(/connected document system/)).toBeDefined();
});
});
Use the same pattern for selectFiles(). You cannot automate ChatGPT’s native file library picker in a normal local E2E test, but you can unit test the code after the picker returns:
test('handles selected files from the ChatGPT file library', async () => {
const selectFiles = vi.fn().mockResolvedValue([
{ fileId: 'file_1', fileName: 'notes.txt', mimeType: 'text/plain' },
]);
(window as any).openai = { selectFiles };
render(<LibraryPicker />);
await userEvent.click(screen.getByText('Pick from library'));
expect(selectFiles).toHaveBeenCalled();
expect(await screen.findByText('notes.txt')).toBeDefined();
});
Then test the missing-helper branch:
test('hides the library picker when selectFiles is unavailable', () => {
(window as any).openai = {};
render(<LibraryPicker />);
expect(screen.queryByText('Pick from library')).toBeNull();
});
That branch matters because OpenAI documents the file library as optional. A control that appears but cannot open the picker is a production bug.
Test Downloads with the Host, Not Just href
Sandboxed MCP App iframes often cannot rely on normal browser downloads. The portable pattern is a host-mediated download request.
sunpeak exposes that through useDownloadFile, which supports embedded text content, embedded base64 content, and resource links.
import { useDownloadFile } from 'sunpeak';
export function ExportButton({ rows }: { rows: unknown[] }) {
const downloadFile = useDownloadFile();
return (
<button
type="button"
onClick={() =>
downloadFile({
contents: [
{
type: 'resource',
resource: {
uri: 'file:///exports/report.json',
mimeType: 'application/json',
text: JSON.stringify(rows, null, 2),
},
},
],
})
}
>
Export JSON
</button>
);
}
Unit test the download request shape:
let mockDownloadFile = vi.fn();
vi.mock('sunpeak', () => ({
useDownloadFile: () => mockDownloadFile,
}));
test('exports rows through host-mediated download', async () => {
render(<ExportButton rows={[{ id: 1, total: 42 }]} />);
await userEvent.click(screen.getByText('Export JSON'));
expect(mockDownloadFile).toHaveBeenCalledWith({
contents: [
{
type: 'resource',
resource: expect.objectContaining({
uri: 'file:///exports/report.json',
mimeType: 'application/json',
}),
},
],
});
});
If you use ChatGPT fileId references, getFileDownloadUrl({ fileId }) is a different path. It returns a temporary URL, so the UI must handle loading and failure:
test('resolves a ChatGPT fileId before rendering a download link', async () => {
(window as any).openai = {
getFileDownloadUrl: vi.fn().mockResolvedValue({ downloadUrl: 'https://files.example/report.pdf' }),
};
render(<ChatGPTFileLink fileId="file_abc123" fileName="report.pdf" />);
const link = await screen.findByText('Download report.pdf');
expect(link).toHaveAttribute('href', 'https://files.example/report.pdf');
});
Also test rejection:
test('shows a retry state when a temporary file URL cannot be created', async () => {
(window as any).openai = {
getFileDownloadUrl: vi.fn().mockRejectedValue(new Error('expired')),
};
render(<ChatGPTFileLink fileId="file_abc123" fileName="report.pdf" />);
expect(await screen.findByText('Download link expired. Try again.')).toBeDefined();
});
Temporary URLs expire by design. Your UI should not cache them as durable file locations.
Use Simulations for File States
sunpeak simulations are JSON fixtures in tests/simulations/ that pin a tool name, input, result, and optional server tool mocks. For file handling, they replace hard-to-reproduce live states with deterministic fixtures.
Start with a success fixture:
{
"tool": "show-report",
"userMessage": "Open the quarterly report",
"toolInput": {
"reportId": "q2-2026"
},
"toolResult": {
"structuredContent": {
"file": {
"id": "file_abc123",
"name": "quarterly-report.pdf",
"mimeType": "application/pdf",
"size": 482193,
"status": "ready"
},
"summary": "Quarterly revenue report with 8 pages"
}
}
}
Then add the states that break:
- No file returned.
- Unsupported host.
- Upload rejected by size or MIME type.
- Temporary download URL expired.
- File metadata exists but the server-side import is still processing.
- Binary resource has an unsupported MIME type.
- User cancelled a host-mediated download.
The E2E test should assert what the user sees in each state:
import { test, expect } from 'sunpeak/test';
test('report viewer renders file metadata from a simulation', async ({ inspector }) => {
const result = await inspector.renderTool('show-report');
const app = result.app();
await expect(app.getByText('quarterly-report.pdf')).toBeVisible();
await expect(app.getByText('application/pdf')).toBeVisible();
});
For host-specific branches, keep the test explicit:
test('ChatGPT file action is visible only when the host supports it', async ({ inspector }) => {
const result = await inspector.renderTool('show-report');
const app = result.app();
if (inspector.host === 'chatgpt') {
await expect(app.getByRole('button', { name: 'Open in ChatGPT files' })).toBeVisible();
} else {
await expect(app.getByRole('button', { name: 'Open in ChatGPT files' })).not.toBeVisible();
await expect(app.getByText('Use the connected document system to open this file.')).toBeVisible();
}
});
Avoid broad skips for portable behavior. Skip or branch only around the feature that truly belongs to one host.
Test Server-Side File Security
File handling security belongs on the server. The iframe can help the user choose a file, but the server has to validate what it receives.
Add tests for:
- Maximum file size.
- Allowed MIME types and extensions.
- MIME sniffing or magic-byte checks for risky formats.
- User authorization for the file or upload ID.
- Path traversal in filenames such as
../../secret.txt. - Duplicate upload IDs and idempotency behavior.
- Virus scanning or malware checks when your product needs them.
- Cleanup after failed uploads.
Example server test:
import { test, expect } from 'sunpeak/test';
test('rejects unsupported upload MIME types', async ({ mcp }) => {
const result = await mcp.callTool('create-upload', {
fileName: 'payload.exe',
mimeType: 'application/x-msdownload',
size: 12000,
});
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('Unsupported file type');
});
And test filenames like data, not paths:
test('does not trust filenames as storage paths', async ({ mcp }) => {
const result = await mcp.callTool('create-upload', {
fileName: '../../private/report.pdf',
mimeType: 'application/pdf',
size: 12000,
});
expect(result.isError).toBe(true);
});
This is the part a live ChatGPT or Claude smoke test will not catch. Keep it in fast CI.
Cross-Host Test Matrix
For a file-heavy ChatGPT App, Claude Connector, or portable MCP App, use this matrix:
| Layer | What to verify | Runs where |
|---|---|---|
| Protocol test | Tool input schema, outputSchema, structuredContent, _meta, resource content | Local CI |
| Server file test | Size, MIME, extension, auth, storage, cleanup | Local CI |
| Component unit test | Host API mocks, loading states, error states, fallback text | Local CI |
| Inspector E2E test | Rendered iframe UI across host replicas and simulations | Local CI |
| Visual test | File cards, previews, progress, empty states, narrow layouts | Local CI |
| Live-host smoke test | Native picker opens and returns expected metadata | Slow path before release |
The live-host test should stay small. It should prove that the native host path still works after deployment. Most coverage belongs in deterministic local tests because that is where you can run every file state on every change.
With sunpeak’s testing framework, the usual workflow is:
pnpm test:unit
pnpm test:e2e
pnpm test:visual
For an existing MCP server that is not built with sunpeak, scaffold tests around it:
npx sunpeak test init --server http://localhost:8000/mcp
Then add simulations for file states and use the inspector fixture to render those states in local ChatGPT and Claude-style runtimes.
File Handling Test Checklist
Use this before you ship a file workflow:
- The tool contract keeps raw bytes out of
structuredContent. - Binary MCP resources decode to valid bytes and match their MIME type.
- ChatGPT-only APIs are feature-detected before use.
- Claude and non-ChatGPT hosts render a tested fallback.
- Native file picker logic has unit coverage after the picker returns.
- Server upload tools reject unsupported type, size, path, and auth cases.
- Download flows handle cancelled, denied, expired, and retry states.
- Simulation files cover ready, missing, rejected, processing, and error states.
- E2E tests assert the rendered file UI in each supported host profile.
- One live smoke test covers the native host picker if the release depends on it.
Get Started
File handling in MCP Apps is not one API. It is a contract between your tool, resource, host, storage layer, and user-facing fallback.
Build the portable contract first. Add ChatGPT file APIs where they improve the flow. Test Claude and other non-ChatGPT paths with the same care. sunpeak helps by giving you local host replicas, simulation fixtures, protocol tests, and Playwright E2E tests, so file bugs do not have to wait for a live chat session.
Start with npx sunpeak new, add one file simulation, and turn the most fragile manual file check into an automated test before your next deploy.
Get Started
npx sunpeak newFurther Reading
- File Uploads in MCP Apps - ChatGPT Apps, blob resources, and Claude Connectors
- Cross-host compatibility testing for MCP Apps
- MCP App capability detection and fallbacks
- Mocking and stubbing in MCP App tests
- E2E testing MCP Apps with the inspector fixture
- Security testing MCP Apps - file path validation and upload risk
- MCP App tool results - content, structuredContent, and _meta
- sunpeak useDownloadFile hook reference
- sunpeak simulations - deterministic file states for tests
- OpenAI plugin reference - ChatGPT file APIs
- OpenAI ChatGPT UI guide - feature-detect window.openai extensions
- Official MCP Apps overview - resources, iframes, and host bridge
Frequently Asked Questions
How do I test file uploads in an MCP App?
Test file uploads in layers. Unit test the resource component with mocked file APIs. Integration test the server-side validation and storage contract. Render deterministic upload states with sunpeak simulation files. Then run one narrow live-host smoke test for the native picker path if the app depends on ChatGPT file APIs.
What file APIs do ChatGPT Apps expose?
OpenAI documents ChatGPT file handling through window.openai.uploadFile(file, { library?: boolean }), window.openai.selectFiles(), and window.openai.getFileDownloadUrl({ fileId }). uploadFile returns a fileId, selectFiles returns authorized file metadata from the ChatGPT file library when available, and getFileDownloadUrl returns a temporary URL for an authorized file reference. Feature-detect each helper before rendering a control.
Do Claude Connectors support the same file APIs as ChatGPT Apps?
Do not assume they do. Build the portable path around MCP tools, resources, authenticated data sources, and server-side upload or import flows. If the same MCP App runs in ChatGPT, keep ChatGPT-only file controls behind capability detection and verify the Claude fallback in E2E tests.
How do I test file downloads in MCP Apps?
For portable downloads, test the host-mediated download path with embedded text, embedded base64 resource content, or resource links. In sunpeak, use useDownloadFile for host downloads and assert the contents, MIME type, file name, and error branch. For ChatGPT file IDs, test that getFileDownloadUrl is called with the right fileId and that the UI handles temporary URL failures.
Should file bytes go in structuredContent?
Usually no. structuredContent is typed data for the model and app resource, so it should contain concise metadata, IDs, status, and summaries. Use MCP blob resources, resource links, signed URLs, or host file IDs for file bytes. This keeps model-visible context small and avoids leaking raw file data into places that only need metadata.
Can MCP Apps handle binary files like images and PDFs?
Yes. MCP resources can carry binary content as base64 blob data with a MIME type, and MCP Apps can render or download that content through the host. Test binary output by decoding the base64 in a protocol-level test, checking magic bytes, validating the MIME type, and rendering the file state in an inspector E2E test.
How do simulation files help with file handling tests?
Simulation files pin tool input, tool results, structuredContent, and server tool mocks. They let you render file-present, file-missing, upload-error, unsupported-host, large-file, and expired-link states in the local inspector without opening a native file picker, touching real storage, or using a live host account.
What are the most common file handling bugs in MCP Apps?
Common bugs include calling window.openai without feature detection, treating a fileId as a URL, putting base64 content in structuredContent, missing MIME or size validation on the server, trusting filenames as paths, not handling temporary download URL expiry, and only testing ChatGPT while the app also claims to support Claude.