PromptFax
← PromptFax Blog

MCP / file intake / interoperability

A File ID Is Not a File

Hardening PromptFax intake across ChatGPT, Claude, and other MCP hosts

Attaching a PDF to a chat looks like the easy part. The user can see the file. The model can often discuss it. Surely a remote MCP server can just use it too.

That assumption caused more trouble in PromptFax than the actual PDF handling.

“The chat has a file” can describe an opaque host ID, metadata about an attachment, a path inside the model host’s container, a temporary signed URL, or a real upload to storage controlled by the MCP server. Only the last two can become a PromptFax document, and even a temporary URL may stop working before the server fetches it.

We recently changed PromptFax file intake to make those boundaries explicit. ChatGPT now gets a strict, host-specific file parameter contract and the shortest automatic path. Claude and other compatible hosts get the same interactive PromptFax experience through the MCP Apps extension. Text-only clients still get a hosted upload path. Raw IDs, local paths, and attachment-shaped metadata are rejected instead of being mistaken for documents.

This post explains why that took more than adding a files array.

The deceptively hard part: whose file is it?

An MCP server runs outside the model host. PromptFax cannot read ChatGPT’s private file store, Claude’s internal attachment store, or a container filesystem that belongs to either host. The visible attachment and the server-readable document are different objects until the host provides an authorized transfer.

We encountered several payloads that looked useful but were not:

file_abc123
A ChatGPT object identifier. It is not a URL, and PromptFax cannot resolve it server-side.
/mnt/data/promptfax-smoke-test.pdf
A path inside somebody else’s runtime. It does not exist in the PromptFax Cloud Run filesystem.
{ name, mime_type, size }
Useful metadata, but no bytes and no fetchable location.
https://…/download?[signed query]
A usable handoff only while it is valid—and a credential-like value that should not appear in logs.

The first three cases describe a file without transferring it.

What changed for ChatGPT

OpenAI now defines a specific file-input contract for tools. A tool lists its top-level file fields in _meta["openai/fileParams"], and each file object uses four snake-case properties:

{
  "download_url": "https://...",
  "file_id": "file_...",
  "mime_type": "application/pdf",
  "file_name": "referral.pdf"
}

The schema must declare all four. download_url and file_id are required; mime_type and file_name remain optional. PromptFax accepts up to five files on the optional start_session.files field.

ChatGPT conversation showing promptfax-smoke-test.pdf attached to a PromptFax start_session call, followed by confirmation that the PDF was attached without a quote, payment, or fax.
Figure 1. In a no-send test, the composer-attached fixture reached start_session and PromptFax confirmed the PDF was attached. No picker, quote, payment, or fax send was involved.

Our generic compatibility parser still understands older aliases from non-OpenAI clients, but the ChatGPT descriptor advertises only the official fields. When ChatGPT supplies a valid parameter, PromptFax immediately downloads the temporary HTTPS URL into PromptFax-owned storage. The imported object—not the host ID—becomes the document source of truth.

We deliberately do not resolve file_id on the server. OpenAI documents it as useful to the ChatGPT widget when the widget needs a fresh download URL. That is a host capability, not a portable credential for PromptFax.

Failure now preserves the workflow

Temporary URLs can expire, be revoked, or become unreachable. Treating that as a failed session would force the user to start over even though nothing is wrong with the fax workflow.

PromptFax now preserves the session and returns the ChatGPT widget with a sanitized instruction to use Choose PDF from ChatGPT. The error does not include the signed URL, its query string, or host attachment metadata.

  1. Use ChatGPT’s automatic files[] attachment when it is present and fetchable.
  2. Use the ChatGPT file-library picker when the automatic handoff is missing or fails.
  3. Use the hosted PromptFax upload page when an inline host surface is unavailable.
ChatGPT Add from library modal open above the PromptFax widget.
Figure 2. The ChatGPT widget opens the host’s file library when automatic attachment is unavailable. The PromptFax session remains intact while the user chooses a file.

Claude and the cross-host path

There is no reason for a generic MCP client to implement an OpenAI-specific _meta["openai/fileParams"] contract. The broader interoperability path is the MCP Apps UI extension.

PromptFax already had an inline upload application originally built for Claude. We generalized it instead of creating another host-specific integration. The server now checks for the io.modelcontextprotocol/ui extension and support for text/html;profile=mcp-app. A compatible client receives a neutral ui://promptfax/app/... resource and an interactive_app session. Older Claude clients retain a name-based fallback and their previous resource aliases.

Inside the app, the browser has the file bytes because the user selected the file there. The existing upload API transfers those bytes into PromptFax-owned storage. The remote MCP server never has to guess what a Claude attachment ID means.

Claude conversation rendering the PromptFax MCP App with promptfax-smoke-test.pdf ready and payment disabled until a destination is entered.
Figure 3. Claude renders the neutral PromptFax MCP App inline. The selected fixture has been imported and the workflow is ready for a destination, with payment and sending still untouched.

One document boundary, three intake paths

ChatGPT file parameters, an MCP App upload, and a hosted upload are different transport mechanisms, but they all become the same PromptFax-owned document before quoting.

Flow diagram showing ChatGPT file parameters and MCP App uploads converging on PromptFax-owned storage, while raw IDs, local paths, and metadata are rejected.
Figure 4. Host-specific transport ends at a host-neutral, PromptFax-owned document.
If PromptFax cannot fetch or receive the bytes, it does not have a document.

Everything downstream—page inspection, quote creation, payment authorization, fax submission, status, and cleanup—can operate on that invariant without caring which host started the session.

Why the stricter boundary helps

The obvious benefit is fewer clicks in ChatGPT. A composer-attached PDF can now reach PromptFax during start_session without opening the picker. The less obvious benefits are more important.

Predictable failure

An expired URL preserves the session. An opaque ID fails before document creation, not during quoting.

Reduced credential exposure

Signed URLs are fetched immediately, not stored as canonical locations, and query strings are redacted.

Clear ownership

The host owns its file helpers. PromptFax owns the imported document. The transfer is explicit.

Portable workflow state

A session can survive one intake path and continue through another without losing its identity.

Less host-name coupling

New clients can receive the neutral app by advertising a capability, not by matching a brand string.

Better tests

The exact descriptor, redaction behavior, and four-host compatibility matrix are deterministic.

What this does not solve

There still is no universal cross-host attachment object. OpenAI’s file parameter contract is specific to ChatGPT. MCP Apps standardizes an interactive UI transport, not automatic access to every file already attached to a conversation. Text-only clients still need a hosted upload or a real HTTPS URL.

Temporary URLs also remain temporary. Immediate import reduces the failure window; it cannot eliminate host-side expiration or network failure. That is why the picker and hosted page remain part of the design rather than temporary compatibility hacks.

Finally, a successful file import does not authorize a fax. PromptFax still requires a user-reviewed quote and Stripe authorization before sending. Our host tests for this feature were deliberately no-send checks.

The broader lesson

The tempting abstraction is “attachments.” The useful abstraction is “a transfer of bytes into the system that must act on them.”

Once we used that boundary, the design became straightforward: make the shortest host-specific path precise, import immediately, negotiate the standards-based interactive path, preserve explicit fallbacks, and reject values that merely look like files.

That is a little more plumbing than accepting a string called file_id. It is also the difference between a demo that works in one chat and a document workflow that can survive real hosts, real expiration, and real user behavior.