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.
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.
- Use ChatGPT’s automatic
files[]attachment when it is present and fetchable. - Use the ChatGPT file-library picker when the automatic handoff is missing or fails.
- Use the hosted PromptFax upload page when an inline host surface is unavailable.
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.
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.
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.