Most MCP servers answer in milliseconds and cost nothing to call. Ours does neither. The BudgetPixel MCP server lets agents like Claude, ChatGPT, Cursor and Codex make images, video, music and sound effects with a user's own account. A video takes minutes, and generate calls spend the user's credits (failed jobs are free).
It's a remote server on streamable HTTP with 31 tools behind one OAuth sign-in, written in TypeScript as a thin layer over our existing API. Here's what slow, paid jobs taught us about building one, including a bug we'd rather you didn't repeat.
Disclosure: BudgetPixel is our product. This post was written with help from AI, and we checked every detail against our server's code.
1. Return a job id, then teach the agent to poll
A generate call can't hold a connection open for three minutes, so every generate_* tool starts a job and returns its id straight away. A separate check_generation_status tool collects the result.
The tool description tells the agent how often to ask: every few seconds for images and sound effects, every 15–30 seconds for video, music and 3D models.
One sentence in our server instructions saves users more money than anything else:
Each generate or upscale call starts a new paid job: don't repeat a call that already returned a job_id — poll it instead.
Without it, an agent that thinks a call stalled "retries" it, and the user pays twice.
2. Put the price list where the agent reads it
MCP servers can send instructions when a session starts, and many clients pass them to the model. Ours hold the costs and the rules for spending:
Default video model seedance-2.5, priced per second by resolution (480p=150, 720p=330, 1080p=750 credits/s) ... Prefer 480p, and confirm the cost with the user before 1080p, long clips or more than 5 s. Prices change, so a list_models tool returns the live catalog with each model's price and input limits. The instructions say not to assume a fixed list.
Video also has a draft step. The agent makes a 480p draft at the 480p price, and only if the user likes it does finalize_video_draft render the same video at 1080p. An idea that doesn't work fails at the cheap stage.
3. Design results for a chat window
Chat apps differ in what they show, so results carry more than one form:
- Finished images come back inline as image content and as a full-resolution link, because some chat apps don't show inline images.
- Video, music and sound effects come back as links, since chat can't embed a player.
- Some outputs are H.265 (HEVC) 10-bit, which some browsers play as a black frame with sound. The file is fine, so the tool result says when a video is H.265 and the agent can tell the user.
Results don't last forever either. Links last about an hour and files about a day, and a save_to_assets tool keeps a copy in the user's library. The instructions spell that out, so the agent can tell the user to save what they want to keep.
4. Tool output is a transcript: never put a token in a link
Videos made on our website play through a private path that uses the account's login token. The easy way to show one in chat would be a link with the token in it.
That link would land in a chat transcript the user doesn't control, and the token opens the whole account for 7 days. So the tool links to the page where the video lives instead. Treat anything you put in a tool result as public.
5. Make failures something the host can act on
Every tool result goes through one final pass before it leaves the server:
- A failed call gets
isError: true. A job that ran and failed is a normal result, though, and is left alone. - Raw backend errors (
API error 403: {...}) become plain sentences. - When our API rejects the token, the result carries an OAuth challenge, so the chat app offers to reconnect instead of showing an error the user can't act on:
return { content: [{ type: "text", text: "Your BudgetPixel sign-in has expired or isn't valid. Reconnect your BudgetPixel account in this app, then try again." }], structuredContent: { error: "not_authenticated" }, isError: true, _meta: { "mcp/www_authenticate": [ `Bearer resource_metadata="${publicUrl}/.well-known/oauth-protected-resource", error="invalid_token"`, ], }, }; When a plan or balance falls short, the result says exactly what's missing and links an informational pricing page, never a checkout page. ChatGPT's app review guidelines ask for this, and it's the right call anyway: an agent shouldn't be a sales funnel.
6. The bug: a token captured too early
Our first version read the user's token once, when the MCP session was created, and handed it to the code that calls our API.
That broke in Claude Code. Claude Code starts the session before the user signs in, and only sends the Authorization header on later requests. We had frozen an empty token for the whole session, so every API call came back 401.
The fix is to pass a getter, not a string:
// Before: the token as it was when the session started (empty in Claude Code). const server = createBudgetPixelServer(session.token); // After: read the session's current token on every call. const server = createBudgetPixelServer(() => session.token); If your server keeps per-session state, check which values can change after the session starts.
7. With open client registration, the redirect URI is the safety check
MCP clients register themselves with OAuth dynamic client registration (RFC 7591). Claude, ChatGPT, Cursor and VS Code each show up as a new client. An allowlist or an initial access token would lock them all out, so registration stays open.
That leaves the redirect URI as the one thing a phisher controls that the user can check. Our connect page names where the sign-in will land. It recognizes known hosts (claude.ai, chatgpt.com, vscode.dev) and app schemes (cursor, vscode, windsurf), and warns when it can't vouch for one.
We reject very little: only URI shapes no real client uses, such as browser-launcher schemes that would hand the code to a web browser. A stricter rule would leave some real client unable to connect at all. The unauthenticated OAuth endpoints are rate-limited, and the sign-in pages are static markup under a strict content security policy.
8. URLs in tool arguments are an SSRF hole
Many tools take image or video URLs as inputs. Our server runs inside a cluster, so a plain fetch() of a user's URL could reach internal services or the cloud metadata endpoint at 169.254.169.254, and hand back whatever it found.
Checking the hostname before fetching isn't enough, because DNS can answer differently a moment later (DNS rebinding). So the check runs inside the socket's own DNS lookup, and the address that's checked is the address that's connected to:
function guardedLookup(hostname, options, callback) { dns.lookup(hostname, { all: true, family }, (err, addresses) => { if (err) return callback(err); // Refuse if ANY address is private or reserved: a mixed record set is how rebinding hides. if (addresses.some((a) => isBlockedAddress(a.address))) { return callback(new SafeFetchError("blocked_address", "that host resolves to a private or reserved address")); } return options.all ? callback(null, addresses) : callback(null, addresses[0].address, addresses[0].family); }); } https.request(url, { agent: false, lookup: guardedLookup }, onResponse); agent: false matters: a pooled keep-alive socket would skip the lookup, and with it the check. Each redirect hop goes through the same check, and the body streams against a byte cap under one deadline, so a huge or endless response can't pin the server's memory.
Annotate every tool
All 31 tools carry a title and the readOnlyHint, destructiveHint and openWorldHint annotations, so hosts know which calls only read and which change something. check_generation_status is read-only. create_social_post publishes to the user's public feed, so the instructions allow it only when the user explicitly asks, with an hourly cap and a "via MCP" label on every post.
The short version
- Return job ids fast, teach polling, and never let a paid call run twice.
- Put prices and confirmation rules in the server instructions.
- Send images inline and as links, and say when a file might not play.
- Treat tool output as public: no tokens in links.
- Shape errors so the host can act on them.
- Read auth on every call, not once per session.
- With open client registration, make the redirect URI visible and checkable.
- Guard every URL fetch at the socket's DNS lookup.
If you want to see how it behaves from the agent's side, setup for each client is at budgetpixel.com/mcp. We'd like to hear how others handle long-running, paid tools in MCP.

![Humanoid Electrical Architecture — Ch.13: Diagnostics and OTA [EN]](https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj8lqk47hi55f5ccyhwyg.png)