All posts

File Uploads in MCP Apps: ChatGPT Apps, Blob Resources, and Claude Connectors

Abe Wheeler
MCP AppsMCP App FrameworkMCP App TestingChatGPT AppsChatGPT App FrameworkChatGPT App TestingClaude ConnectorsClaude Connector FrameworkFile UploadsFile Handling
MCP App file flows need separate paths for user uploads, server-generated files, and host-specific file APIs.

MCP App file flows need separate paths for user uploads, server-generated files, and host-specific file APIs.

File uploads are one of the first places an MCP App stops feeling like a normal web app.

In a browser, you add <input type="file">, send a FormData request, and your backend receives bytes. In an MCP App, the UI runs inside a sandboxed host iframe, the model may call server tools before the UI appears, and each host decides which native file APIs it exposes.

TL;DR: Use MCP blob resources when your server sends binary files to the app or user. Use host-specific APIs only when you have feature detection, such as ChatGPT’s window.openai file APIs. For portable MCP Apps and Claude Connectors, treat user uploads as a server-side workflow: signed upload URL, authenticated import, or a normal tool that processes an existing file reference. Keep file bytes out of structuredContent, validate every file on the server, and test the fallback path as carefully as the native picker path.

Why File Uploads Are the Search Gap

The current MCP App search surface has good answers for tools, resources, structuredContent, display modes, CSP, and OAuth. File uploads cut across all of those, so builders run into scattered questions:

  • Can an MCP tool accept a PDF?
  • Should a ChatGPT App use uploadFile or a normal <input type="file">?
  • How do Claude Connectors receive user-selected files?
  • Where should base64 content go?
  • Does a file belong in content, structuredContent, _meta, or a resource?
  • How do you test the native file picker in CI?

Those are not one API question. They are three different flows that need different designs.

Three File Flows

Start by naming the direction of the file movement.

FlowExampleBest current pattern
Server to userGenerate a CSV, PDF, image, or ZIPReturn an MCP resource with blob and mimeType, or return a signed download URL
User to serverUser uploads a local PDF for parsingUse a host file API where available, or upload to your backend through a signed URL
Existing cloud file to serverUser asks the app to analyze a doc already stored elsewhereUse OAuth and server-side API access, then pass file metadata through tool results

Most bugs come from mixing these up. A server-generated CSV should not be stuffed into structuredContent. A user upload should not rely on the model pasting base64. A file already stored in a user’s account should usually be imported by your server after OAuth, not copied through the chat transcript.

What MCP Standardizes Today

The MCP resources specification already covers binary resource contents. A resource can include a URI, a MIME type, and a base64 blob.

That is useful for files your server produces or already controls:

{
  "uri": "file:///reports/invoice-summary.pdf",
  "mimeType": "application/pdf",
  "blob": "base64-encoded-data"
}

For MCP Apps, that server-to-client path is only half the story. The harder problem is user-selected input. The MCP File Uploads Working Group exists because developers currently fall back to weak patterns such as asking users to paste base64, pass local paths, or upload files through a separate web page.

As of June 19, 2026, treat declarative file input descriptors as active standardization work, not as a portable baseline you can assume every host supports. Build the portable path first, then add native host affordances when they exist.

What ChatGPT Adds

ChatGPT Apps can use optional file handling APIs through the window.openai runtime. OpenAI’s ChatGPT UI guide describes file handling as a ChatGPT extension, including uploadFile, selectFiles, and getFileDownloadUrl.

Use those APIs when you are intentionally building a ChatGPT App experience, but keep them behind a feature check:

import { useState } from 'react';
import { SafeArea, useCallServerTool } from 'sunpeak';
import { isChatGPT, useUploadFile } from 'sunpeak/chatgpt';

export function UploadInvoiceResource() {
  const [selectedFile, setSelectedFile] = useState<File | null>(null);
  const [status, setStatus] = useState<'idle' | 'uploading' | 'ready' | 'error'>('idle');
  const uploadFile = useUploadFile();
  const callServerTool = useCallServerTool();

  async function handleSubmit() {
    if (!selectedFile || !isChatGPT()) return;

    setStatus('uploading');

    try {
      const uploaded = await uploadFile(selectedFile);

      await callServerTool('analyze_invoice_file', {
        fileId: uploaded.fileId,
        fileName: selectedFile.name,
        mimeType: selectedFile.type,
        size: selectedFile.size,
      });

      setStatus('ready');
    } catch {
      setStatus('error');
    }
  }

  if (!isChatGPT()) {
    return (
      <SafeArea className="p-4">
        <p>Upload the invoice in your connected billing system, then ask this app to import it.</p>
      </SafeArea>
    );
  }

  return (
    <SafeArea className="p-4">
      <input
        type="file"
        accept="application/pdf,image/png,image/jpeg"
        onChange={(event) => setSelectedFile(event.currentTarget.files?.[0] ?? null)}
      />
      <button type="button" disabled={!selectedFile || status === 'uploading'} onClick={handleSubmit}>
        Analyze invoice
      </button>
      {status === 'error' ? <p>Upload failed. Try a smaller PDF or image.</p> : null}
    </SafeArea>
  );
}

The UI sends only a file reference and metadata to the server tool. The server still owns validation, authorization, parsing, and storage. The iframe should not make trust decisions just because the user picked a file.

The Cross-Host Upload Pattern

For portable MCP Apps and Claude Connectors, use a backend-mediated upload flow.

The app asks your server for an upload target. The server returns a short-lived signed URL and an upload ID. The resource uploads the file to that URL. Then the resource calls an app-only server tool with the upload ID so the server can validate and process the file.

// src/tools/create-upload.ts
import { z } from 'zod';
import type { AppToolConfig, ToolHandlerExtra } from 'sunpeak/mcp';

export const tool: AppToolConfig = {
  title: 'Create Upload',
  description: 'Create a short-lived upload URL for an invoice file.',
  annotations: {
    readOnlyHint: false,
    destructiveHint: false,
    idempotentHint: false,
  },
  _meta: {
    ui: { visibility: ['app'] },
  },
};

export const schema = {
  fileName: z.string().max(160),
  mimeType: z.enum(['application/pdf', 'image/png', 'image/jpeg']),
  size: z.number().int().positive().max(10 * 1024 * 1024),
};

export default async function createUpload(args: z.infer<z.ZodObject<typeof schema>>, extra: ToolHandlerExtra) {
  const userId = extra.authInfo?.clientId;
  if (!userId) {
    return {
      isError: true,
      content: [{ type: 'text', text: 'Sign in before uploading invoices.' }],
    };
  }

  const upload = await createSignedUpload({
    userId,
    fileName: args.fileName,
    mimeType: args.mimeType,
    size: args.size,
    expiresInSeconds: 300,
  });

  return {
    structuredContent: {
      uploadId: upload.id,
      uploadUrl: upload.url,
      expiresAt: upload.expiresAt,
    },
    content: [{ type: 'text', text: 'Created a short-lived upload URL.' }],
  };
}

Then finish processing with a separate tool:

// src/tools/process-upload.ts
import { z } from 'zod';
import type { AppToolConfig, ToolHandlerExtra } from 'sunpeak/mcp';

export const tool: AppToolConfig = {
  title: 'Process Uploaded Invoice',
  description: 'Validate and parse an uploaded invoice file.',
  annotations: {
    readOnlyHint: false,
    destructiveHint: false,
    idempotentHint: true,
  },
  _meta: {
    ui: { visibility: ['app'] },
  },
};

export const schema = {
  uploadId: z.string(),
};

export default async function processUpload(args: z.infer<z.ZodObject<typeof schema>>, extra: ToolHandlerExtra) {
  const userId = extra.authInfo?.clientId;
  if (!userId) {
    return {
      isError: true,
      content: [{ type: 'text', text: 'Sign in before processing invoices.' }],
    };
  }

  const file = await loadUploadForUser(args.uploadId, userId);
  await validateStoredFile(file);

  const summary = await parseInvoice(file);

  return {
    structuredContent: {
      invoiceId: summary.invoiceId,
      vendor: summary.vendor,
      totalCents: summary.totalCents,
      dueDate: summary.dueDate,
      status: 'parsed',
    },
    content: [
      {
        type: 'text',
        text: `Parsed invoice ${summary.invoiceId} from ${summary.vendor}.`,
      },
    ],
  };
}

That pattern works because the MCP App is only coordinating the upload. Your server owns the file lifecycle.

What Goes in Tool Results

Do not put file bytes in structuredContent.

Use structuredContent for the small data the model and app can reason about:

{
  "invoiceId": "INV-1042",
  "fileName": "invoice-1042.pdf",
  "mimeType": "application/pdf",
  "size": 384912,
  "status": "parsed",
  "totalCents": 129900,
  "dueDate": "2026-07-15"
}

Use _meta for UI-only helper values when the host supports it:

{
  "downloadUrl": "https://files.example.com/download/signed-token",
  "previewUrl": "https://files.example.com/preview/signed-token"
}

Use a blob resource or signed URL for binary output:

return {
  content: [{ type: 'text', text: 'Generated invoice export for May 2026.' }],
  structuredContent: {
    fileName: 'invoice-export-may-2026.csv',
    rowCount: 418,
  },
  _meta: {
    downloadUrl: exportUrl,
  },
};

If the file itself is small and meant to be returned through MCP, serve it as a resource with the right MIME type. If the file is large, private, or likely to be downloaded more than once, a short-lived signed URL is easier to cache, audit, and revoke.

Security Rules

File uploads are input validation with higher stakes.

The server should enforce:

  • Maximum file size before storage and before parsing.
  • MIME type allowlists, checked from server-side sniffing rather than only browser-provided file.type.
  • Extension checks, but never extension-only checks.
  • Per-user ownership on every upload ID.
  • Virus or malware scanning when users upload documents that other people can later download.
  • Path normalization if your tool reads or writes local files.
  • Short retention windows for temporary uploads.

Never use the original filename as a storage path:

// Unsafe
const destination = `/uploads/${args.fileName}`;

// Safer
const uploadId = crypto.randomUUID();
const destination = `/uploads/${userId}/${uploadId}`;

The filename is display metadata. It is not an address.

Cross-Host UI Behavior

Render different controls based on capability, not based only on host name.

Good file UI has three branches:

  • Native file control available: show the host-native upload or picker flow.
  • Browser upload available: show your signed upload flow.
  • No upload available: point the user at an authenticated import flow or explain which connected system to use.

For example:

function FileActionBar() {
  const canUseChatGPTFiles = isChatGPT() && typeof window.openai?.uploadFile === 'function';
  const canUseBackendUpload = typeof window.fetch === 'function';

  if (canUseChatGPTFiles) {
    return <ChatGPTFileUpload />;
  }

  if (canUseBackendUpload) {
    return <SignedUrlUpload />;
  }

  return <ImportFromConnectedAccount />;
}

The exact implementation depends on your framework, but the rule is stable. Detect the capability you need, then provide a real fallback.

Testing the File Flow

Test file handling in layers.

First, unit test the UI branches. Mock the host API so you can assert that the upload button appears only when the capability exists.

Second, test server validation directly. Pass fake files with bad MIME types, oversized payloads, mismatched extensions, expired upload IDs, and upload IDs owned by another user.

Third, render deterministic states in the inspector:

{
  "tool": "process-upload",
  "userMessage": "Analyze this invoice",
  "toolInput": {
    "uploadId": "upl_test_123"
  },
  "toolResult": {
    "structuredContent": {
      "invoiceId": "INV-1042",
      "fileName": "invoice-1042.pdf",
      "mimeType": "application/pdf",
      "size": 384912,
      "status": "parsed",
      "totalCents": 129900,
      "dueDate": "2026-07-15"
    },
    "content": [
      {
        "type": "text",
        "text": "Parsed invoice INV-1042."
      }
    ]
  }
}

Then assert the resource renders the parsed state, empty state, failed scan state, unsupported file state, expired upload state, and permission-denied state.

Save live-host testing for the native picker path. You do not need a live ChatGPT or Claude session to test parser logic, storage ownership, MIME validation, UI rendering, or file-state layout. You only need the real host to prove its picker returns the shape you expect.

With sunpeak’s MCP App testing framework, that split is the default workflow: local simulations and Playwright tests cover the deterministic states, while live tests stay small.

A Practical Build Order

Build file support in this order:

  1. Define the server-side file contract: max size, MIME types, auth, retention, parser output, and error states.
  2. Build the upload or import tool pair.
  3. Add a resource UI that renders every state from simulation files.
  4. Add ChatGPT-native file APIs behind feature detection.
  5. Add cross-host fallback UI for Claude Connectors and other MCP App hosts.
  6. Add one live-host smoke test for native file selection.

This avoids the common trap: getting a native picker to open once, then discovering the real bugs are in parser errors, user ownership, expired links, and unsupported host behavior.

Where sunpeak Fits

sunpeak does not make file uploads magically portable across hosts. No framework can do that while the standard is still settling.

What sunpeak does is give you a shorter loop for the parts you can and should own: tool contracts, app-only server tools, simulation files, cross-host rendering, browser upload fallback states, and automated tests. You can build the portable path locally, add ChatGPT-specific file APIs where useful, and verify the fallback path in the same test matrix before users see it.

Start with:

npx sunpeak new

Then add simulations for every file state before wiring the live picker. File bugs are much easier to fix when the failing state is a JSON fixture, not a one-off manual upload inside a host session.

Get Started

Documentation →
npx sunpeak new

Further Reading

Frequently Asked Questions

Can MCP Apps upload files?

Yes, but the path depends on the host and direction. MCP resources already support binary file delivery from server to client through blob resource contents. User-selected file uploads are still being standardized in the MCP File Uploads Working Group. ChatGPT Apps can use ChatGPT-specific file APIs today, while cross-host MCP Apps should provide a fallback path such as uploading to your backend through a signed URL or asking the user to authenticate with the system that already stores the file.

What is the difference between MCP blob resources and file uploads?

Blob resources are for server-to-client file delivery. Your MCP server returns binary data such as an image, PDF, or CSV with a MIME type, and the host or app resource can display or download it. File uploads are client-to-server. The user chooses a local file or host-library file, then the app or host needs to send that file content to a server-side tool or storage endpoint.

Does ChatGPT support file uploads in Apps?

ChatGPT documents file handling as a ChatGPT extension through window.openai APIs such as uploadFile, selectFiles, and getFileDownloadUrl. Use feature detection or a framework helper before rendering file controls because those APIs are not part of the portable MCP Apps baseline and may not exist in other hosts.

Do Claude Connectors support the same file upload APIs as ChatGPT Apps?

Do not assume they do. Build Claude Connector file flows around standard MCP tools, existing authenticated data sources, server-side imports, or your own upload endpoint. If the same MCP App also runs in ChatGPT, gate ChatGPT-only file controls behind host feature detection and render a useful fallback elsewhere.

Should I put base64 file data in structuredContent?

Usually no. structuredContent is model-visible in many MCP App hosts and should stay concise. Put file metadata, IDs, status, and small summaries in structuredContent. Use MCP blob resources for server-generated binary output, or upload large user files to storage and pass short-lived references through tools.

How should I secure MCP App file uploads?

Validate file type, size, extension, MIME type, and user authorization on the server. Store uploads outside the app iframe, scan files before processing where your risk model requires it, use short-lived signed upload or download URLs, and never trust a filename as a filesystem path. Treat every file as user-supplied input even when it came through a trusted AI host.

How do I test file uploads in MCP Apps?

Test the contract in layers. Unit test upload UI branches with mocked host APIs, integration test server-side file validation and storage, render file states in the local inspector with simulation files, and run a small live-host smoke test only for the native host picker path. sunpeak lets you run most of this locally and in CI without paid host accounts.