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. One call, no human step — this is the path for an asset an agent already has a URL for, including one it just generated.

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

URL mode is SSRF-guarded

file_url must be https:// — the schema rejects http:// outright. The server-side fetch then runs through the same SSRF guard the rest of the app uses (blackops-mcp/src/security/ssrf-guard.ts, mirroring src/lib/security/safe-fetch.ts in the main app): it blocks private, loopback, link-local and cloud-metadata addresses (127.0.0.1, 169.254.169.254, 10.0.0.0/8, 192.168.0.0/16, and their IPv6 equivalents, including an IPv4 address embedded in an IPv6 literal) for both IPv4 and IPv6, and blocks internal-looking hostnames (.local, .internal, .corp, .lan, .home, .intranet, localhost) without needing a DNS lookup to do it. A redirect is re-checked against the same rules before it's followed — a public URL that 302s to 169.254.169.254 is rejected exactly like a direct request to it would be. The fetched response's declared Content-Type is also checked against the content_type you passed, but loosely: a generic application/octet-stream (common for S3 presigned URLs) or the image/jpg ↔ image/jpeg alias isn't treated as a mismatch — only a clearly different type (the URL actually serves an HTML error page, say) is rejected.

Size and timeout limits: 50 MB for images and PDFs — the same cap quick-upload enforces — checked against the response's declared Content-Length before any body is read, and enforced again while streaming so a response that omits or understates Content-Length can't force an unbounded download. The whole fetch aborts after 30 seconds.

Video is capped lower in URL mode: 200 MB, not quick-upload's 1 GB. The 1 GB bucket limit is what the agent-direct-PUT path below supports (bytes never route through this fetch, so there's no timeout to fit inside); fetching video through URL mode holds the whole response in this process for one bounded call, and 1 GB isn't reliably fetchable inside any fetch timeout short enough to still be a tool call rather than a stall. 200 MB in 30 seconds needs about 53 Mbps, comfortable for most hosted file storage. For a video bigger than that, use the agent-direct-PUT path.

content_type is checked against the allowed list up front too, so an unsupported type is rejected before any network call at all.

Each rejection comes back as a distinct error code so a caller can tell them apart: FILE_URL_MUST_BE_HTTPS, FILE_URL_BLOCKED (SSRF), FILE_TOO_LARGE, FILE_URL_FETCH_TIMEOUT, FILE_URL_REDIRECT_REJECTED, UNSUPPORTED_CONTENT_TYPE, CONTENT_TYPE_MISMATCH.

Agent-direct upload — no browser, no human step, no fetch-and-relay

An agent that can make an HTTP PUT and has no public URL yet for its file (a locally-generated image before it's hosted anywhere, for instance) doesn't need a human at all:

  1. Mint a presigned upload URL. Call the get_media_upload_url MCP tool with { domain, filename, content_type, size_bytes } (or POST https://www.blackopscenter.com/api/v2/media/upload-url with the same bearer token and body, if you are outside MCP). Returns { asset_id, upload_url, public_url, site_url, expires_in }. upload_url is a one-hour-expiry Supabase Storage signed URL.
  2. PUT the bytes. PUT upload_url with the file's raw bytes and Content-Type set to the same content_type passed above. Nothing else is needed — no multipart, no form fields.
  3. Finalize. Call the finalize_media_upload MCP tool with { asset_id, domain }. This needs only the asset id and the MCP/API token already on the session — no browser, no cookie, no interactive handoff of any kind. It validates the bytes, builds a thumbnail (for an image or PDF), and returns the ready asset.
# 1. Mint the presigned URL (same bearer token as the MCP session)
curl -X POST https://www.blackopscenter.com/api/v2/media/upload-url \
  -H "Authorization: Bearer $BLACKOPS_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"filename":"chart.png","content_type":"image/png","size_bytes":48213,"domain":"benenewton.com"}'
# → { "data": { "asset_id": "...", "upload_url": "https://...", "public_url": "...", "expires_in": 3600 } }

# 2. PUT the bytes straight to storage
curl -X PUT "$UPLOAD_URL" -H "Content-Type: image/png" --data-binary @chart.png
# 3. Finalize via MCP — asset_id from step 1
finalize_media_upload({ asset_id: "...", domain: "benenewton.com" })

This is the same presigned-URL mechanism URL mode uses internally (upload_media with file_url does steps 1–3 itself, server-side); this path exists for when there's no file_url yet to hand it. /api/v2/media/upload-url applies the same content-type allowlist and size cap as everywhere else before minting the URL.

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.
POST /api/v2/media/upload-urlV2 token auth (no browser). Validates content_type/size_bytes against the allowlist and cap, mints a one-hour presigned Supabase Storage URL, creates a pending asset row. Returns { asset_id, upload_url, public_url, site_url, expires_in }. Drives upload_media URL mode internally, and is callable directly for agent-direct upload (see above).
POST /api/v2/media/finalizeV2 token auth (no browser). Takes { asset_id, domain }, verifies the bytes landed in storage, generates dimensions/thumbnail, marks the asset ready. This is what finalize_media_upload calls.
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).
  • get_media_upload_url — mints the presigned { asset_id, upload_url } for the agent-direct path. PUT the bytes to upload_url, then call finalize_media_upload. Same allowlist and size caps as the REST route it wraps.
  • finalize_media_upload — finishes a presigned upload: call after upload_media URL mode handled it automatically, or after an agent PUT bytes directly to a get_media_upload_url-minted URL. Token-only auth; no browser or session.
  • 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 and agent-direct upload take images (jpeg, png, webp, gif) and PDFs up to 50 MB per file, and video (mp4, mov, webm) up to 1 GB. URL mode enforces the same 50 MB for images/PDF, but caps video lower, at 200 MB — see "URL mode is SSRF-guarded" above for why.
  • 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. It does not apply to URL mode or agent-direct upload either, for the same reason — nothing pastes the bytes into the conversation for the assistant to look at.
  • In a bulk upload, your prepared filename/alt/title apply to the first image only; the rest auto-generate.
  • file_url must be https://; a redirect that lands on a blocked address is rejected exactly like fetching it directly would be.
Want this page as machine-readable markdown? GET /docs/features/image-upload.md