All posts

Testing File Handling in MCP Apps: Uploads, Downloads, and Cross-Host Compatibility

Abe Wheeler
MCP Apps MCP App Testing MCP App Framework ChatGPT Apps ChatGPT App Testing ChatGPT App Framework Claude Connectors Claude Connector Testing Claude Connector Framework File Upload File Handling
Testing file uploads and downloads in MCP Apps across ChatGPT and Claude.

Testing file uploads and downloads in MCP Apps across ChatGPT and Claude.

File handling is one of the hardest things to test in MCP Apps. The file APIs are host-specific (ChatGPT has uploadFile, selectFiles, and getFileDownloadUrl, while Claude doesn’t), the file picker is a native dialog you can’t automate, and binary content adds encoding and MIME type concerns on top of regular tool data. Most teams skip file tests entirely and debug in production. That’s expensive.

TL;DR: Mock file hooks with vi.mock("sunpeak/chatgpt") for unit tests. Use simulation files with file references for E2E tests. Test the host detection gate so your app degrades correctly on hosts without file APIs. Run everything locally with pnpm test:unit and pnpm test:e2e. No paid accounts needed.

How File Handling Works in MCP Apps

MCP Apps handle files through two separate channels, and understanding the split is the key to testing them correctly.

The MCP protocol layer supports binary content natively. Your tool handler can return images, PDFs, and other files as base64-encoded blob resources with MIME types. This works on every host that implements the MCP Apps spec. When your tool returns a blob, the host delivers it to your resource component through the same tool-result notification that carries structured content.

The ChatGPT host layer adds three file APIs on top of the protocol:

  • uploadFile(file, options) lets your resource component upload a file to ChatGPT’s storage. Returns a file ID.
  • selectFiles() opens the ChatGPT file library so the user can pick existing files. Returns an array of file IDs.
  • getFileDownloadUrl({ fileId }) converts a file ID into a temporary download URL.

These live on window.openai and are available only inside ChatGPT. Claude, Goose, VS Code, and other hosts don’t support them (yet). In sunpeak, they’re wrapped as typed React hooks under sunpeak/chatgpt.

This split creates two distinct testing problems: testing binary content returned through the protocol (works everywhere), and testing the ChatGPT-specific file APIs (works only on one host, needs fallback on others).

Unit Testing File Upload Components

Start with the resource component that triggers file uploads. A typical file upload component looks like this:

import { useToolData } from 'sunpeak';
import { useUploadFile, isChatGPT } from 'sunpeak/chatgpt';

export function FileEditor() {
  const { output } = useToolData();
  const uploadFile = useUploadFile();

  const handleUpload = async (file: File) => {
    const result = await uploadFile(file);
    // Use result.fileId to reference the uploaded file
  };

  return (
    <div>
      <h2>{output?.title}</h2>
      {isChatGPT() ? (
        <button onClick={() => document.getElementById('file-input')?.click()}>
          Upload File
        </button>
      ) : (
        <p>File upload available in ChatGPT</p>
      )}
      <input
        id="file-input"
        type="file"
        hidden
        onChange={(e) => e.target.files?.[0] && handleUpload(e.target.files[0])}
      />
    </div>
  );
}

The component has three states you need to test: upload available (ChatGPT), upload unavailable (other hosts), and upload completed (file ID received). Here’s how to cover them:

import { describe, test, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import { FileEditor } from '../src/resources/file-editor';

// Module-level mocks
let mockOutput: any = { title: 'My Document' };
let mockUploadFile = vi.fn();
let mockIsChatGPT = vi.fn(() => true);

vi.mock('sunpeak', () => ({
  useToolData: () => ({
    output: mockOutput,
    isError: false,
    isLoading: false,
    isCancelled: false,
  }),
}));

vi.mock('sunpeak/chatgpt', () => ({
  useUploadFile: () => mockUploadFile,
  isChatGPT: () => mockIsChatGPT(),
}));

describe('FileEditor', () => {
  beforeEach(() => {
    mockOutput = { title: 'My Document' };
    mockUploadFile = vi.fn();
    mockIsChatGPT = vi.fn(() => true);
  });

  test('shows upload button on ChatGPT', () => {
    render(<FileEditor />);
    expect(screen.getByText('Upload File')).toBeDefined();
  });

  test('shows fallback on non-ChatGPT hosts', () => {
    mockIsChatGPT.mockReturnValue(false);
    render(<FileEditor />);
    expect(screen.queryByText('Upload File')).toBeNull();
    expect(screen.getByText('File upload available in ChatGPT')).toBeDefined();
  });

  test('calls uploadFile with selected file', async () => {
    mockUploadFile.mockResolvedValue({ fileId: 'file-abc-123' });
    render(<FileEditor />);

    const input = document.getElementById('file-input') as HTMLInputElement;
    const testFile = new File(['test content'], 'test.txt', {
      type: 'text/plain',
    });

    // Simulate file selection
    Object.defineProperty(input, 'files', { value: [testFile] });
    input.dispatchEvent(new Event('change', { bubbles: true }));

    expect(mockUploadFile).toHaveBeenCalledWith(testFile);
  });
});

The host detection gate (isChatGPT()) is the most common source of bugs in file handling components. A wrong gate means a broken upload button renders on Claude or a missing upload button on ChatGPT. Test both branches explicitly.

Unit Testing File Downloads

When your tool handler returns file references, your resource component needs to resolve them into download URLs. The flow looks like this: the tool returns a file ID in structuredContent, your component calls getFileDownloadUrl, and renders the result.

import { useToolData } from 'sunpeak';

export function FileViewer() {
  const { output } = useToolData();
  const [downloadUrl, setDownloadUrl] = useState<string | null>(null);

  useEffect(() => {
    if (output?.fileId && window.openai?.getFileDownloadUrl) {
      window.openai
        .getFileDownloadUrl({ fileId: output.fileId })
        .then((url) => setDownloadUrl(url));
    }
  }, [output?.fileId]);

  if (!output) return null;

  return (
    <div>
      <h2>{output.fileName}</h2>
      {downloadUrl ? (
        <a href={downloadUrl} download>
          Download {output.fileName}
        </a>
      ) : output.fileId ? (
        <span>Loading download link...</span>
      ) : null}
    </div>
  );
}

Test the three states: no file, file loading, and file resolved.

let mockOutput: any;

vi.mock('sunpeak', () => ({
  useToolData: () => ({
    output: mockOutput,
    isError: false,
    isLoading: false,
    isCancelled: false,
  }),
}));

describe('FileViewer', () => {
  beforeEach(() => {
    // Mock window.openai for file API
    (window as any).openai = {
      getFileDownloadUrl: vi.fn().mockResolvedValue('https://cdn.example.com/file.pdf'),
    };
  });

  test('renders download link when file ID resolves', async () => {
    mockOutput = { fileName: 'report.pdf', fileId: 'file-xyz-789' };
    render(<FileViewer />);

    // Wait for the async URL resolution
    const link = await screen.findByText('Download report.pdf');
    expect(link).toBeDefined();
    expect(link.getAttribute('href')).toBe('https://cdn.example.com/file.pdf');
  });

  test('shows loading state while URL resolves', () => {
    (window as any).openai.getFileDownloadUrl = vi.fn(
      () => new Promise(() => {}) // Never resolves
    );
    mockOutput = { fileName: 'report.pdf', fileId: 'file-xyz-789' };
    render(<FileViewer />);
    expect(screen.getByText('Loading download link...')).toBeDefined();
  });

  test('renders without download when no file API exists', () => {
    delete (window as any).openai;
    mockOutput = { fileName: 'report.pdf', fileId: 'file-xyz-789' };
    render(<FileViewer />);
    expect(screen.queryByText('Download report.pdf')).toBeNull();
  });
});

The third test catches a subtle production bug: if window.openai is undefined (which it will be on Claude, Goose, and every non-ChatGPT host), your component shouldn’t crash. Feature-detect the API before calling it.

Testing Binary Content from Tool Handlers

When your tool handler returns binary content through the MCP protocol (not through ChatGPT-specific APIs), the content arrives as base64-encoded blob data. This path works on every host.

A tool handler that returns an image:

server.tool('generate-chart', { data: z.array(z.number()) }, async ({ data }) => {
  const chartBuffer = await renderChart(data);

  return {
    content: [
      {
        type: 'resource',
        resource: {
          uri: 'chart://latest',
          mimeType: 'image/png',
          blob: chartBuffer.toString('base64'),
        },
      },
    ],
    structuredContent: {
      title: 'Sales Chart',
      chartUri: 'chart://latest',
    },
  };
});

Test the handler with an integration test using the mcp fixture:

import { test, expect } from 'sunpeak/test';

test('generate-chart returns valid PNG blob', async ({ mcp }) => {
  const result = await mcp.callTool('generate-chart', {
    data: [10, 20, 30, 40],
  });

  expect(result.isError).toBeFalsy();

  // Check the blob resource
  const resource = result.content.find((c) => c.type === 'resource');
  expect(resource).toBeDefined();
  expect(resource.resource.mimeType).toBe('image/png');

  // Verify it's valid base64
  const buffer = Buffer.from(resource.resource.blob, 'base64');
  expect(buffer.length).toBeGreaterThan(0);

  // PNG magic bytes
  expect(buffer[0]).toBe(0x89);
  expect(buffer[1]).toBe(0x50);
});

This test verifies your tool handler produces valid binary output without rendering anything in a browser. It runs on every host because it uses the MCP protocol directly.

E2E Testing File Flows

E2E tests with the inspector fixture let you test file handling in a rendered MCP App. Create a simulation file that includes file references:

{
  "tool": "show-document",
  "userMessage": "Show the quarterly report",
  "toolInput": {
    "documentId": "doc-123"
  },
  "toolResult": {
    "title": "Q1 2026 Report",
    "fileName": "q1-report.pdf",
    "fileId": "file-abc-456",
    "sections": [
      { "heading": "Revenue", "value": "$2.4M" },
      { "heading": "Growth", "value": "18%" }
    ]
  }
}

Then write an E2E test that renders the tool with this simulation and checks the file-related UI:

import { test, expect } from 'sunpeak/test';

test('document viewer renders file download link', async ({ inspector }) => {
  const result = await inspector.renderTool('show-document');
  const app = result.app();

  // Check document content renders
  await expect(app.locator('text=Q1 2026 Report')).toBeVisible();
  await expect(app.locator('text=$2.4M')).toBeVisible();

  // Check file download section
  if (inspector.host === 'chatgpt') {
    await expect(app.locator('text=Download q1-report.pdf')).toBeVisible();
  } else {
    // On non-ChatGPT hosts, file download link should be absent
    await expect(app.locator('text=Download q1-report.pdf')).not.toBeVisible();
  }
});

This test runs against both ChatGPT and Claude hosts automatically (sunpeak’s test config creates separate Playwright projects per host). The conditional assertion catches a common bug: rendering a download link that doesn’t work because the file API is missing.

Testing the File Library Picker

The selectFiles() API opens a native ChatGPT dialog for picking files from the user’s library. You can’t automate the native picker in tests, but you can test everything around it.

For unit tests, mock the response:

test('handles selected files from library', async () => {
  const mockSelectFiles = vi.fn().mockResolvedValue([
    { id: 'file-1', name: 'notes.txt' },
    { id: 'file-2', name: 'data.csv' },
  ]);

  (window as any).openai = { selectFiles: mockSelectFiles };

  render(<FilePicker />);

  // Trigger the "Pick from Library" action
  await userEvent.click(screen.getByText('Pick from Library'));

  expect(mockSelectFiles).toHaveBeenCalled();
  // Verify selected files appear in the UI
  expect(await screen.findByText('notes.txt')).toBeDefined();
  expect(await screen.findByText('data.csv')).toBeDefined();
});

Also test the “not available” path. The file library is gated by feature detection because it’s not available on every ChatGPT plan or environment:

test('hides library picker when selectFiles is unavailable', () => {
  (window as any).openai = {}; // No selectFiles
  render(<FilePicker />);
  expect(screen.queryByText('Pick from Library')).toBeNull();
});

Cross-Host File Handling Tests

The biggest testing gap for file handling is at the host boundary. Your app needs to work on ChatGPT (with file APIs) and Claude (without them). The cross-host testing guide covers the general pattern. For files specifically, test these scenarios:

ChatGPT-only file feature present on ChatGPT:

test('upload button works on ChatGPT', async ({ inspector }) => {
  test.skip(inspector.host !== 'chatgpt', 'ChatGPT-only feature');

  const result = await inspector.renderTool('show-editor');
  const app = result.app();
  await expect(app.locator('[data-testid="upload-btn"]')).toBeVisible();
});

ChatGPT-only file feature absent on Claude:

test('upload button hidden on Claude', async ({ inspector }) => {
  test.skip(inspector.host === 'chatgpt', 'Testing absence of ChatGPT feature');

  const result = await inspector.renderTool('show-editor');
  const app = result.app();
  await expect(app.locator('[data-testid="upload-btn"]')).not.toBeVisible();
});

Binary content renders on all hosts:

test('chart image renders from blob content', async ({ inspector }) => {
  const result = await inspector.renderTool('generate-chart', {
    data: [10, 20, 30],
  });
  const app = result.app();

  // This should work on every host because it uses MCP protocol blobs
  await expect(app.locator('img[alt="Sales Chart"]')).toBeVisible();
});

The pattern is straightforward: test host-specific features with test.skip gates, and test protocol-level features (blobs, structured content) without gates because they should work everywhere.

Common File Handling Bugs and How to Catch Them

Here are the file handling bugs that show up most often in production, and the test that catches each one:

Bug: Component crashes when window.openai is undefined. This happens on every non-ChatGPT host. The fix is feature detection (window.openai?.uploadFile). The test: render your component in a unit test with window.openai deleted and verify it doesn’t throw.

Bug: File ID used without resolving to a URL. A file ID is a ChatGPT-internal reference, not a URL. If you render it as an href, the link is broken. The test: check that your download links start with http or https, not a bare file ID string.

Bug: Upload succeeds but component doesn’t update. The uploadFile response contains a file ID, but if your state update races with a re-render, the file might not appear. The test: mock uploadFile to return a file ID, trigger the upload, and use findByText (which waits for async updates) to verify the file appears.

Bug: Base64 blob content corrupted during encoding. If your tool handler double-encodes or truncates the base64 string, the binary content is broken. The test: decode the base64 in your integration test and check the magic bytes (PNG starts with 0x89 0x50, PDF starts with 0x25 0x50).

Bug: MIME type mismatch. Your handler returns image/png but the blob is actually a JPEG. The browser might render it anyway, but some hosts are stricter. The test: compare the MIME type in the resource metadata against the actual content bytes.

Running File Handling Tests

File handling tests run with the same commands as every other MCP App test:

# Unit tests for file components
pnpm test:unit

# E2E tests across ChatGPT and Claude hosts
pnpm test:e2e

# Visual regression for file UI components
pnpm test:visual

# A specific file handling test
pnpm test:unit tests/unit/file-editor.test.tsx
pnpm test:e2e tests/e2e/file-handling.test.ts

All tests run locally and in CI without paid accounts, API keys, or live file storage. Simulation files provide deterministic file references, and mocks replace the ChatGPT file APIs.

Get Started

File handling adds complexity to MCP Apps because the APIs are split between the cross-host MCP protocol and ChatGPT-specific extensions. Without tests, you’re debugging file upload bugs by deploying to ChatGPT and clicking around, and you won’t catch the breakage on Claude until a user reports it.

sunpeak gives you the tools to test all of this locally: vi.mock("sunpeak/chatgpt") for unit tests, simulation files with file references for E2E, and the inspector fixture that runs your app against both hosts. Get started with npx sunpeak new and add file handling tests before your first deploy.

Get Started

Documentation →
npx sunpeak new

Further Reading

Frequently Asked Questions

How do I test file uploads in an MCP App?

For unit tests, mock the ChatGPT-specific useUploadFile hook from sunpeak/chatgpt with vi.mock() and verify your component handles the returned file ID. For E2E tests, use the inspector fixture from sunpeak/test with a simulation file that includes file references in the tool input. sunpeak replicates the ChatGPT file API locally, so you can test upload flows without a paid account.

How do I handle file downloads in MCP App tests?

When your tool returns file references in structuredContent, test that your resource component calls getFileDownloadUrl with the correct file ID and renders the download link or preview. In unit tests, mock getFileDownloadUrl to return a predictable URL. In E2E tests, include file references in your simulation file toolResult and assert that the rendered UI shows the file content or download link.

Do I need a ChatGPT account to test file handling in MCP Apps?

No. sunpeak runs a local inspector that replicates both the ChatGPT and Claude host runtimes, including file handling APIs. Unit tests mock file hooks directly, and E2E tests use simulation files with file references. All file handling tests run locally and in CI/CD without paid subscriptions, API keys, or AI credits.

How do I test file handling across ChatGPT and Claude?

File APIs like uploadFile, selectFiles, and getFileDownloadUrl are ChatGPT-specific extensions. Claude does not currently support these APIs. Test that your app detects the host and either uses the file APIs on ChatGPT or shows a fallback on other hosts. Use inspector.host in E2E tests to conditionally run file-specific assertions on ChatGPT and verify fallback UI on Claude.

What is useUploadFile in sunpeak?

useUploadFile is a ChatGPT-specific React hook exported from sunpeak/chatgpt. It wraps the window.openai.uploadFile API and returns a function your component calls to upload a file to ChatGPT. The hook handles feature detection automatically. In tests, mock it with vi.mock("sunpeak/chatgpt") to return a controlled mock function and file ID.

How do I mock file APIs in MCP App unit tests?

Use vi.mock("sunpeak/chatgpt") to replace file hooks with mock implementations. Set your mock to return controlled file IDs and download URLs so tests stay deterministic. Change mock return values between tests to cover success, error, and "API not available" cases. This catches bugs in your file handling logic without hitting any real file storage.

Can MCP Apps handle binary files like images and PDFs?

Yes. The MCP protocol supports binary content through base64-encoded blob resources with MIME types. Tool handlers can return images, PDFs, and other binary files as blob content. On ChatGPT, users can also upload files through the uploadFile API. Test binary handling by including base64 blob data in your simulation file toolResult and asserting that your component renders the correct preview or download link.

How do I test the file library picker in a ChatGPT App?

The selectFiles API lets users pick files from their ChatGPT library. In unit tests, mock it to return an array of file IDs. In E2E tests, you cannot trigger the native ChatGPT file picker, but you can test the code path by providing file IDs in simulation data and verifying your component handles selected files correctly. Test the "not available" path too, since the file library is not available on every host or environment.