Skip to main content
All posts

File Uploads in MCP Apps: ChatGPT Apps, Blob Resources, and Claude Connectors (September 2026)

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 behaving like a normal web app.

In a normal browser page, you can add <input type="file"> and send FormData to your API. An MCP App runs in a sandboxed iframe inside an AI host. The host may expose a native file library, the model may call a tool before the app renders, and the app can only reach network origins allowed by its Content Security Policy.

TL;DR: Name the direction and owner of every file before choosing an API. Use MCP resources or the MCP Apps downloadFile request for server-to-user files. Use ChatGPT’s optional file APIs or file tool parameters when you need its native file flow. For a cross-host MCP App or Claude Connector, use a short-lived backend upload session or an authenticated cloud import. Keep raw bytes and bearer URLs out of structuredContent, validate the stored object on the server, and test every state locally before one small live-host check.

Start With Direction, Ownership, and Visibility

“File upload” can describe four different transfers. They have different trust boundaries.

DirectionExampleCurrent pattern
Local user file to your serverUpload a PDF invoiceHost file API when available, otherwise a signed backend upload
Host library file to your serverChoose a file already stored in ChatGPTHost library picker or host-declared file tool input
Cloud account file to your serverImport a document from a connected driveOAuth plus a server-side provider API call
Your server to the userExport a CSV, PDF, image, or ZIPMCP resource, MCP Apps downloadFile, or a short-lived resource link

For each path, write down four facts:

  1. Who chose or created the file?
  2. Which service stores the authoritative bytes?
  3. Does the model need the bytes, a summary, or only an ID?
  4. Which principal is allowed to fetch, parse, replace, and delete it?

This catches design mistakes early. A generated CSV does not belong in structuredContent. A local PDF should not travel as base64 pasted into a prompt. A file in a user’s cloud account should usually move server to server after OAuth, so the chat transcript never becomes a file transport.

What MCP Standardizes in September 2026

The MCP 2026-07-28 resources specification defines text and binary resource contents. Binary content uses a MIME type and a base64 blob:

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

Resources cover server-to-client delivery. The server owns the bytes and the client decides how to expose them. The latest specification also lets an https:// resource point at content the client can fetch directly.

The MCP Apps extension adds downloadFile, a host-mediated way for an app View to ask the host to save a file. It accepts embedded text, embedded binary content, or a resource_link:

import { useDownloadFile } from 'sunpeak';

function ExportButton({ downloadUrl }: { downloadUrl: string }) {
  const downloadFile = useDownloadFile();

  return (
    <button
      type="button"
      onClick={() =>
        downloadFile({
          contents: [
            {
              type: 'resource_link',
              uri: downloadUrl,
              name: 'invoice-summary.pdf',
              mimeType: 'application/pdf',
            },
          ],
        })
      }
    >
      Download PDF
    </button>
  );
}

This avoids iframe download restrictions and gives the host control over the user-facing download. Feature-detect the host’s downloadFile capability and provide an external-link fallback when it is absent.

User-selected input is different. The MCP File Uploads Working Group is drafting a schema-level mechanism for tools and elicitation requests to declare file inputs. Its charter lists SEP-2356 and SDK helpers as active work. As of September 13, 2026, do not treat that draft as a baseline supported by every host. The portable production path still needs a fallback.

Use Blob Resources for Small Server Output

A blob resource works well when your server already has a small binary object and the host needs it inline. It is less useful for a 100 MB archive because base64 adds about one third to the payload before JSON overhead, and the host must hold the full response in memory.

Use this rough split:

FileBetter delivery
Small image or PDF that belongs in an MCP responseBlobResourceContents
Generated text exportEmbedded text passed to downloadFile
Large, private, or reusable exportShort-lived resource_link passed to downloadFile
Data the model should reason overSmall text summary or typed structuredContent

Claude’s current MCP App troubleshooting guide also warns that very large tool results may be moved to a sandbox file instead of delivered inline to the app. Keep the initial result small, then fetch or download the file on demand.

What ChatGPT Adds

ChatGPT layers optional file extensions on top of MCP Apps:

  • window.openai.uploadFile(file, { library?: boolean }) uploads a user-selected file and returns a fileId.
  • window.openai.selectFiles() opens the user’s ChatGPT file library when that feature is available.
  • window.openai.getFileDownloadUrl({ fileId }) returns a temporary URL for an authorized file.
  • _meta["openai/fileParams"] tells ChatGPT which top-level tool input fields accept files.

These are documented ChatGPT Plugin extensions, not shared MCP Apps methods. Feature-detect the exact helper you need. A host-name check alone is too broad because file-library access can vary by user and surface.

sunpeak provides useUploadFile for ChatGPT-specific app UI. The current helper wraps window.openai.uploadFile and fails clearly when the app is not connected or the runtime method is missing:

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

export function UploadImage() {
  const [file, setFile] = useState<File | null>(null);
  const [status, setStatus] = useState<'idle' | 'uploading' | 'ready' | 'error'>('idle');
  const uploadFile = useUploadFile();
  const callServerTool = useCallServerTool();
  const canUpload = typeof window.openai?.uploadFile === 'function';

  async function submit() {
    if (!file || !canUpload) return;
    setStatus('uploading');

    try {
      const { fileId } = await uploadFile(file);
      await callServerTool({
        name: 'analyze_uploaded_image',
        arguments: {
          fileId,
          fileName: file.name,
        },
      });
      setStatus('ready');
    } catch {
      setStatus('error');
    }
  }

  if (!canUpload) {
    return <SignedUploadFallback />;
  }

  return (
    <SafeArea className="p-4">
      <input
        type="file"
        accept="image/png,image/jpeg,image/webp"
        onChange={(event) => setFile(event.currentTarget.files?.[0] ?? null)}
      />
      <button type="button" disabled={!file || status === 'uploading'} onClick={submit}>
        Analyze image
      </button>
      {status === 'error' ? <p>Upload failed. Check the file and try again.</p> : null}
    </SafeArea>
  );
}

The file ID is a host reference, not proof that your server may trust the file’s name, type, content, or owner. Resolve the file through an authorized host path, then apply your own validation before processing it.

Let ChatGPT Attach Files to a Tool Call

ChatGPT can pass files to a tool without the app View uploading them first. Add each top-level file field to _meta["openai/fileParams"]. The field schema must declare download_url, file_id, mime_type, and file_name, while requiring only the first two:

{
  "name": "analyze_invoice",
  "inputSchema": {
    "type": "object",
    "properties": {
      "file": {
        "type": "object",
        "properties": {
          "download_url": { "type": "string" },
          "file_id": { "type": "string" },
          "mime_type": { "type": "string" },
          "file_name": { "type": "string" }
        },
        "required": ["download_url", "file_id"],
        "additionalProperties": false
      }
    },
    "required": ["file"]
  },
  "_meta": {
    "openai/fileParams": ["file"]
  }
}

ChatGPT supplies snake-case file values at runtime. Treat download_url as a temporary bearer URL: fetch it server-side, do not write it to logs, do not return it in another tool result, and do not store it as the permanent file address. Use file_id when the app later needs a fresh URL through getFileDownloadUrl.

The Portable Signed Upload Pattern

For local files in a cross-host app, use your own upload session. The bytes go straight from the sandboxed View to object storage, while MCP tools coordinate authorization and finalization.

  1. The app calls an app-only create_upload tool with display name, claimed MIME type, and byte size.
  2. The server authenticates the request, checks policy, creates an upload record, and returns a five-minute signed URL in tool-result _meta.
  3. The View uploads the bytes to that URL.
  4. The app calls an app-only finalize_upload tool with the opaque upload ID.
  5. The server checks ownership and the stored object, scans or parses it, then marks the record ready.

Keep the signed URL out of model-visible structuredContent:

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

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

export const schema = {
  fileName: z.string().min(1).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 accountId = await requireAccountId(extra.authInfo);
  const upload = await createSignedUpload({
    accountId,
    displayName: args.fileName,
    claimedMimeType: args.mimeType,
    expectedBytes: args.size,
    expiresInSeconds: 300,
  });

  return {
    content: [{ type: 'text' as const, text: 'Upload session created.' }],
    _meta: {
      upload: {
        id: upload.id,
        url: upload.url,
        expiresAt: upload.expiresAt,
      },
    },
  };
}

requireAccountId is your authentication boundary. Do not assume an OAuth client ID is the end user’s account ID. Derive the app account from a validated token or session, and repeat that ownership check in finalize_upload.

The finalize tool should load the upload by both opaque ID and account ID, reject expired or already-consumed sessions, compare the actual byte count, inspect the file signature, run any required scanner, and parse in an isolated worker. Only then should it return model-visible business data:

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

Declare an outputSchema for that object so hosts, tests, and your View agree on the result contract.

Configure CSP and CORS for Browser Uploads

A signed URL does not bypass the iframe’s network rules. The MCP App resource must include the upload origin in _meta.ui.csp.connectDomains:

_meta: {
  ui: {
    csp: {
      connectDomains: ['https://uploads.example.com'],
    },
  },
}

Your storage endpoint must also answer the View’s request with the right CORS headers. CSP says which origins the browser may contact. CORS says which iframe origin the upload server accepts. When the API requires a stable allowlisted origin, configure _meta.ui.domain according to each host’s rules.

Test the preflight as well as the upload. A PUT with a content type or checksum header can trigger an OPTIONS request that fails even when a simple GET succeeds. Keep the signed method, path, headers, size, and expiry as narrow as your storage provider allows.

Keep Each Value in the Right Data Lane

Tool-result fields have different readers:

LaneGood contentsKeep out
contentShort result summary for the model and userRaw bytes, signed URLs, parser logs
structuredContentTyped IDs, metadata, status, parsed factsSecrets, bearer URLs, full documents
Result _metaShort-lived app-only cursor or upload session dataLong-lived credentials, authoritative state
MCP resource blobSmall server-owned binary contentLarge uploads, data the model only needs summarized
resource_linkFetchable server output for host downloadPermanent public access to private files

In ChatGPT, tool-result _meta is kept out of model context, but the app’s browser code can read it. Treat it as View-only, not secret storage. Put permanent authorization state on your server.

An upload URL is authority to write an object. A download URL is authority to read one. Make both short-lived, scope each to one object and operation, and avoid exposing either to the model unless the host contract requires it.

Use an Explicit File State Machine

A boolean uploaded flag cannot describe a real document pipeline. Track a small state machine:

  • requested: the server issued an upload session. Move to uploading or expired.
  • uploading: the client started transferring bytes. Move to uploaded, expired, or rejected.
  • uploaded: the object exists but is not trusted yet. Move to scanning or rejected.
  • scanning: validation and security checks are running. Move to ready or rejected.
  • ready: the server accepted the file for app use. Continue with product-specific states.
  • rejected: policy, validation, or scanning failed. Stop, or issue a new session after the user corrects the problem.
  • expired: the upload session timed out. Stop, or issue a new session.

The View should render all of these. “Upload complete” should mean the server accepted the file, not merely that the browser received a 200 response from object storage.

Security Checks That Matter

Treat every file as untrusted input, even when a trusted host supplied the reference.

  • Enforce the maximum size before issuing a URL, at object storage, and again before parsing.
  • Allowlist expected formats and inspect file signatures. Browser MIME types and extensions are hints.
  • Bind each upload record to an authenticated app account and check ownership on every read, finalize, retry, and delete.
  • Generate storage keys from server IDs. Keep the original filename only as normalized display metadata.
  • Quarantine risky files before another user or parser can open them.
  • Isolate parsers and set CPU, memory, recursion, archive expansion, and time limits.
  • Reject path traversal, nested archive bombs, malformed PDFs, active HTML or SVG, and remote-reference fetches you did not intend.
  • Strip unneeded metadata such as EXIF when the product does not need it.
  • Record upload ID, account ID, state transitions, scanner result, parser version, and deletion, but never log bytes or bearer URLs.
  • Define retention and deletion rules for failed, temporary, and accepted files.

If your app imports a URL supplied by a user or model, add server-side request forgery controls. Resolve and validate the destination, block private and link-local networks, limit redirects, cap response size, and do not forward your service credentials to the fetched origin.

Build One Cross-Host UI

The shared MCP Apps baseline now covers app resources, host bridge calls, resources, and host-mediated downloads. Claude supports MCP Apps as interactive connectors across Claude surfaces, while ChatGPT supports the same shared app model plus its own optional file helpers.

Use that split directly:

function FileActionBar() {
  const canUseChatGPTUpload = typeof window.openai?.uploadFile === 'function';
  const canUseChatGPTLibrary = typeof window.openai?.selectFiles === 'function';

  if (canUseChatGPTLibrary) return <ChatGPTLibraryPicker />;
  if (canUseChatGPTUpload) return <ChatGPTLocalUpload />;
  if (canUploadToBackend()) return <SignedUploadFallback />;
  return <ImportFromConnectedAccount />;
}

Do not render a dead upload button in Claude or another host. Also do not hide the whole workflow just because a native picker is absent. A signed backend upload or OAuth import is a full product path, not an error screen.

Test the Contract in Layers

Most file bugs do not need a live AI host to reproduce.

First, test server policy and storage:

  • valid file, empty file, and exact size boundary
  • mismatched extension, MIME type, and file signature
  • expired, replayed, unknown, and cross-account upload IDs
  • truncated transfer and checksum mismatch
  • scanner timeout, parser timeout, and parser crash
  • archive expansion and hostile path names
  • deletion and retention jobs

Next, simulate each View state with fixed tool input, result, host context, and capabilities. Assert that the UI shows the right action for requested, uploading, scanning, ready, rejected, and expired. Test the ChatGPT branches with mocked uploadFile and selectFiles, then test the fallback with both APIs absent.

Run a browser integration test for the signed upload. Verify the CSP allows only the intended origin, the CORS preflight succeeds, the exact signed headers are sent, progress does not reset the layout, cancellation works, and the finalize tool receives only the opaque ID.

Finally, inspect protocol results. Assert that raw bytes and signed URLs never appear in content or structuredContent, that the final object matches outputSchema, and that a resource link expires as designed.

Keep live-host coverage small: one ChatGPT picker or file-parameter check, one Claude fallback check, and one download check for each host you support. Parser, storage, scanner, and UI-state tests should run locally and in CI.

Where sunpeak Fits

sunpeak 0.20.84 supports the MCP Apps protocol, local replicas of ChatGPT and Claude host behavior, simulation files, hot reload, browser automation, and CI tests. It also exposes a portable useDownloadFile hook and a ChatGPT-specific useUploadFile hook.

That lets you keep native host checks at the edge of the system. Build the tools, result lanes, upload state machine, CSP, fallback UI, and failure states against deterministic local inputs. Then spend live-host time only on behavior the host owns.

Start a project with:

npx sunpeak new

Add upload simulations before wiring a live picker. A repeatable rejected or expired fixture is faster to debug than a one-off manual upload inside a host session, and it can protect the same behavior on every pull request.

Get Started

Documentation →
npx sunpeak new

Further Reading

Frequently Asked Questions

Can MCP Apps upload files?

Yes, but user-selected uploads do not yet have one portable MCP Apps API. ChatGPT exposes optional file APIs and file tool parameters. A cross-host app can upload directly to its own backend with a short-lived signed URL or import a file from an authenticated cloud service. The MCP File Uploads Working Group is drafting a standard way for tools and elicitation requests to declare file inputs.

What is the difference between an MCP blob resource and a file upload?

A BlobResourceContents object sends base64-encoded binary data from an MCP server to a client. A user upload moves bytes in the other direction, from a local picker or host file library to your service. Use blob resources or the MCP Apps downloadFile request for server-generated output. Use a host file API, signed upload URL, or authenticated import for user input.

How does ChatGPT pass a file to an MCP tool?

ChatGPT can pass file objects through top-level tool input fields declared in _meta["openai/fileParams"]. Each file object includes download_url and file_id, with optional mime_type and file_name. ChatGPT also offers optional window.openai helpers named uploadFile, selectFiles, and getFileDownloadUrl for app UI.

Do Claude Connectors have the same file APIs as ChatGPT Apps?

Do not assume they do. Claude supports MCP Apps as interactive connectors, but ChatGPT file helpers are host extensions rather than the shared MCP Apps baseline. Use standard tools, resources, downloadFile, backend uploads, or OAuth-backed imports for the portable flow, then feature-detect any host extension you add.

Should I put file bytes or signed URLs in structuredContent?

Usually no. structuredContent can reach the model, so keep it to IDs, metadata, status, and small summaries. Put a short-lived upload URL in tool-result _meta when only the app needs it, and remember that _meta is still visible to browser code. Use a blob resource, resource link, or signed download for binary output.

How should I secure an MCP App file upload?

Bind every upload session to the authenticated account, limit size and type before issuing a URL, use a short expiry, and verify the stored object again before parsing it. Check file signatures rather than trusting the extension or browser MIME type. Quarantine risky formats, scan when needed, normalize display names, isolate parser workers, and block replay after finalization.

How do I test file uploads without a paid ChatGPT or Claude account?

Test most of the flow locally. Mock host file capabilities, simulate each upload state, test CSP and CORS in a real browser, and call the upload-session and finalize tools with valid and hostile inputs. sunpeak can run these deterministic MCP App checks locally and in CI. Keep live-host tests to a small picker and file-reference smoke test.