Documentation

Image & Document Upload (MCP)

Get a screenshot, PDF or video from an AI chat into your media library without pushing the bytes through the model. You finish the upload from a one-time link.

Quick upload is how an MCP-connected assistant gets a pasted screenshot into a BlackOps site's media library without routing the image bytes through the conversation or an MCP tool call. The assistant prepares the metadata, BlackOps hands you a one-time, token-scoped link, you finish the upload in the browser, and the assistant claims the finished asset. The model only ever handles lightweight metadata — the bytes go straight from your browser to storage.

Why this exists

The only naive way to upload a clipboard screenshot through an MCP tool is to base64-encode it and pass it as a parameter. A single 1–2 MB image becomes roughly half a million tokens of text in one call — it consumes the context window, slows every turn after it, and bills you for noise. In practice it froze the conversation entirely. This was logged and fixed as the "MCP Media Upload Context Freeze". The byte-bearing params (file_data, base64, file_path) are now rejected outright.

Pasting an image into chat is fine

A common worry: doesn't pasting the image into the chat recreate the same problem? It does not. When you paste an image, the model ingests it as a vision input — it's tokenized as a bounded number of image tokens (the image is downscaled to a max dimension first), typically around 1,000–1,600 tokens. That's cheap and it doesn't balloon.

What froze the context was the other thing: taking the same pixels and encoding them as base64 text inside an MCP tool call. Same image, two wildly different costs:

PathWhat the model storesRough cost
Paste into chat (vision)Tokenized image~1–1.6k tokens
Base64 image as text via MCPRaw text characters~500k+ tokens

So pasting the image so the assistant can see it — to write the description, alt text, and run the safety scan — is the cheap copy. The handoff exists so the full-resolution bytes reach storage without re-pasting them or base64-ing them through a tool call. Two copies, two jobs: a small vision copy for understanding, the original bytes for the library.

The flow

  1. Paste the image(s) into your AI chat. Drop one or more screenshots into the conversation with an MCP-connected assistant. It views them as vision input — it never needs the raw bytes or a file path.
  2. The assistant prepares metadata and runs a safety scan. For each image it writes an SEO-friendly kebab-case filename and descriptive alt text. With the BlackOps media-upload skill installed, it also scans the image for content that should not be shared before anything leaves the machine (see below).
  3. The assistant calls upload_media and returns a link. With the metadata ready, it calls upload_media in interactive mode. BlackOps returns a one-time, token-scoped URL pointing at the quick-upload page.
  4. You open the link and upload. Sign in if asked, then drop or paste images. They upload as they land. Click Done when finished. The bytes go from your browser to your media library — the assistant never touches them.
  5. The assistant claims the finished assets. It calls upload_media({ token }) to retrieve the finalized asset(s), each with an asset_id, hosted URL, and dimensions, and can attach them to a draft post, tweet, thread, or LinkedIn post.

The pre-upload safety scan

The safety scan is not enforced by the server. BlackOps never inspects the bytes. The scan runs only because the BlackOps media-upload skill instructs the assistant to check the image before it calls upload_media. If that skill is not installed, the assistant has no scan instruction and the upload goes through unscanned. Installing the skill is what turns the scan on.

When the skill is active, the assistant also has to be able to actually see the image, so paste it into the session. It then flags:

  • Secrets — API keys, tokens, session cookies, .env values, anything in an open devtools panel
  • Cross-tenant or other-customer data visible in the frame
  • Internal or localhost URLs in the address bar
  • PII — emails, real names, account IDs not meant to be public
  • Stale or draft state, debug overlays, error toasts

If the scan flags anything, the assistant stops and asks you to confirm or supply a cleaned image before uploading.

The scan does not extend to PDFs. It works by the assistant looking at a pasted image, and a PDF is not something it can see that way — a deck pasted into chat is not ingested page by page as vision input. So a PDF uploads unscanned regardless of whether the skill is installed. Check a document yourself before uploading it, particularly a deck built from screenshots, which is exactly the kind of file that carries someone else's data in the corner of a frame.

The three upload modes

upload_media is one tool with three modes:

  • Interactive — pass no file_url and no token. Returns { url, token, expires_at }. The default for pasted screenshots and local files, including screen recordings. One link accepts multiple files.
  • Claim — pass token. Returns all finalized assets once you click Done (status: "completed" with an assets[] array). Returns status: "pending" while you're still uploading — poll again.
  • URL — pass file_url + filename + content_type. The server fetches an already-hosted asset and stores it.

All three modes take images, PDFs and video (MP4, MOV, WebM).

Metadata carry-through: the filename, alt text, and title prepared in chat ride along on the handoff and are applied to the first uploaded image. Additional images in a bulk upload auto-generate their own.

PDFs and documents

A PDF uploads exactly like an image, through any of the three modes, and comes back with the same asset_id and public URL. That URL is an ordinary hosted file: add it to your verified links and link it from a page or post like any other. This is how a lead magnet gets somewhere to live.

The thumbnail is page one. BlackOps rasterizes the first page and composites a PDF badge in the brand orange over the corner, so a document is distinguishable from an image at a glance in the library grid. The rendered page dimensions are stored as the asset's width and height, so documents are not stuck at 0x0 the way a non-image asset would otherwise be.

PDF is the only document type. application/msword, .pages, .key and everything else are rejected with an error naming what is supported. Every other format needs its own rasterizer, so widening this is a deliberate change rather than a one-line edit.

Documents are capped at 50 MB, the same limit the quick-upload page already applied. That clears a slide deck with embedded imagery while still capping abuse. An upload over the cap is rejected with the limit stated in the error.

Two failure modes are treated differently on purpose:

What happenedWhat BlackOps does
The bytes are not really a PDFRejected. The upload fails and the stored object is cleaned up — a declared content type is never taken as evidence, the bytes are checked.
A genuine PDF will not rasterizeStored anyway, with no thumbnail. The file is still the thing you wanted; losing its preview is not worth discarding the upload.

When a document has no thumbnail, list_media and get_media report thumbnail_url as null rather than falling back to the PDF's own URL. That fallback is right for an image — it is the same picture, just heavier — but a PDF in an <img> renders nothing, so null lets you show a placeholder instead of a broken image.

Videos

A video uploads through the quick-upload link the same way an image does: drop an MP4, MOV or WebM file on the page and it goes straight from your browser to storage. This is the way to get a local screen recording or a rendered demo into the library when it is not hosted anywhere yet.

Videos are capped at 1 GB, the storage bucket's own limit. Images and PDFs stay at 50 MB.

Only the start of the file is checked. BlackOps reads the first 64 KB to confirm the bytes really are video and to get the file size, and never downloads the whole file into a server function. A file that declares itself as video but is really an image is rejected, so it cannot claim the larger cap.

No thumbnail and no auto alt text. A video is stored with media_type: "video" and no preview image, the same way URL mode stores one. Pass alt_text on the upload_media call if you want it described.

What never happens

Two byte-bearing parameters are rejected outright by upload_media:

  • file_data / base64 — this was the context-freeze path and is now refused. Use a hosted URL or the interactive link instead.
  • file_path — the MCP server runs remotely and cannot read your local upload directory.

How it works

Handoff creation. upload_media (interactive) calls POST /api/v2/upload-handoffs, which inserts a row in upload_handoffs with a random token, the resolved site_id, the AI-prepared desired_filename / desired_alt_text / desired_title, and an expiry. It returns /quick-upload?token=….

Browser upload. The quick-upload page loads the handoff via GET /api/admin/quick-upload/[token] (cookie session — sign-in required). For each file it asks …/[token]/sign for a one-time signed storage URL, uploads the bytes straight to the site-assets bucket, then calls …/[token]/register, which validates ownership, sniffs the real file type from magic bytes, and writes the image_assets row. A …/[token]/finish call marks the handoff complete so a claim returns the assets.

Byte path. Browser → Supabase storage, via a signed URL. The bytes never pass through the model, the MCP server, or a server function body.

API reference

EndpointPurpose
POST /api/v2/upload-handoffsCreates a token-scoped upload handoff (V2 token auth). Returns { token, url, expires_at, site_id }. Drives upload_media interactive mode.
GET /api/admin/quick-upload/[token]Loads handoff state + finished assets for the quick-upload page. Cookie session; 401 when signed out.
POST /api/admin/quick-upload/[token]/signReturns a one-time signed upload URL for one file after checking its declared type and size.
POST /api/admin/quick-upload/[token]/registerRecords a file the browser uploaded through sign: sniffs its real type, enforces the size cap, creates the asset.
POST /api/admin/quick-upload/[token]/completeAccepts small files as multipart/form-data (under the ~4.5 MB request-body limit), validates the handoff, stores to site-assets, creates the asset. The page itself uses sign + register.
POST /api/admin/quick-upload/[token]/finishMarks the handoff complete when you click Done, so a claim returns status: "completed".

MCP tools

  • upload_media — one tool, three modes (interactive / claim / URL).
  • attach_media_to_post / _tweet / _thread / _linkedin_post — attach a claimed asset_id to a draft. One asset per call; attach several in sequence.
  • list_media / get_media — find and reuse an asset already in the library instead of re-uploading it. Pass media_type: "document" to list just your stored PDFs.

Things to know

  • Links expire. TTL defaults to 15 minutes and caps at 1 hour (ttl_ms). An expired link needs a fresh upload_media call.
  • Quick upload takes images (jpeg, png, webp, gif) and PDFs up to 50 MB per file, and video (mp4, mov, webm) up to 1 GB.
  • The link is bound to the user who created it — you must sign in as that user to complete the upload.
  • The safety scan only runs when the BlackOps media-upload skill is installed. It is the assistant's responsibility, not the server's, and it only works if the assistant can see the image, so paste it into the chat. Without the skill, uploads are not scanned.
  • In a bulk upload, your prepared filename/alt/title apply to the first image only; the rest auto-generate.
Want this page as machine-readable markdown? GET /docs/features/image-upload.md