A user sent us a bug report that was really a screenshot of someone else's chat.
They had asked an AI assistant to build a web page that erases text from an
image using our API, and it produced a complete, confident little demo: file
input, preview pane, fetch call, error handling. It did not work. The error it
reported was CORS.
It was not CORS.
POST https://erasetext.com/api/mcp/erase
Authorization: Bearer et_…
FormData: image = <File>
→ reads JSON { output_url }
Our API lives on a different host. The path is /v1/erase
. The documented
field is image_file
. A success returns image bytes, not JSON. And
/api/mcp/erase
did not exist anywhere: the model had welded "MCP" — which we
do run, as JSON-RPC for agent runtimes — onto an HTTP path, which is not what
MCP is. It also invented x/y/w/h
crop parameters, then closed by recommending
a Node proxy to work around the CORS it had diagnosed.
Everything structural. POST, multipart FormData, a file field, a key in a
header, and a JSON body carrying a URL you can drop straight into an <img>
.
That is the average of ten thousand image APIs, and the average is a reasonable
prior. The model was not being careless. It filled the parts of our API it could
not see with the parts almost every other API has.
Preflight passed, the POST failed, and the browser surfaced an opaque network
error with no readable status. In a console that looks exactly like CORS, and
every assistant will tell you it is CORS. What actually happened is duller: the
marketing domain is a static site, so a POST to an invented path under it never
reached an API at all. Once you see that, the fix stops being about headers.
We could have replied with the correct snippet, and that would have fixed one
person. The next assistant, tomorrow, generates the same URL — because it is
generating from the same priors, not from our docs.
Which reframes the problem: a wrong guess that a thousand people will make is
not a support ticket, it is an unclaimed route on your own domain.
URLs are cheap. So we made the guess true.
/api/mcp/erase
, /api/erase
, /mcp/erase
, and a bare
/erase
— map to the same upstream as /v1/erase
.Authorization: Bearer et_…
is copied into X-Api-Key
, so either works.image
is accepted next to image_file
.output_url
as a data URL — the
exact shape the generated code was already trying to read./api/*
to the API worker, because that is the host models pick.The router, in the Cloudflare Worker that fronts the API:
export function apiUpstreamPath(pathname) {
const p = pathname.replace(/\/+$/, "") || "/";
if (
p === "/erase" ||
p === "/v1/erase" ||
p === "/api/erase" ||
p === "/api/mcp/erase" ||
p === "/mcp/erase"
) {
return "/erase";
}
if (p === "/account" || p === "/v1/account") return "/account";
if (p.startsWith("/v1/") && p.length > 4) return p.slice(3);
return null;
}
/** Hallucinated "MCP HTTP" paths: return JSON `{ output_url }` for <img src>. */
export function wantsDemoJson(pathname) {
const p = pathname.replace(/\/+$/, "") || "/";
return p === "/api/mcp/erase" || p === "/api/erase" || p === "/mcp/erase";
}
Header aliasing is three lines, and worth more than it looks:
const bearer = /^Bearer\s+(et_\S+)/i.exec(
request.headers.get("Authorization") || "",
);
if (bearer && !proxied.headers.get("X-Api-Key")) {
proxied.headers.set("X-Api-Key", bearer[1]);
}
The JSON envelope is the part worth explaining. Our real success response is raw
image bytes, which is right for a pipeline and wrong for a generated demo whose
very next line is res.json()
. So on the compatibility paths only, the bytes
get wrapped:
const dataUrl = `data:${mime};base64,${b64}`;
return Response.json({
output_url: dataUrl, // what the generated code read
output_base64: dataUrl, // …and its second guess
image_base64: b64,
content_type: mime,
});
One deploy later, the demo that had been pasted at us ran unchanged apart from
the API key:
curl -i -X POST https://erasetext.com/api/mcp/erase \
-H 'Authorization: Bearer et_…' \
-F 'image=@photo.jpg'
Aliasing a URL costs nothing and changes no behaviour. Accepting an invented
parameter is a different animal. Those x/y/w/h
crop fields are still a 400
with a machine-readable code, because pretending to support them would mean
silently ignoring what a caller asked for.
That is the line we drew: honour guesses about where the thing is, refuse guesses about what the thing does.
Aliases are a safety net, not a strategy. In the same week we made the contract
machine-readable where assistants actually look: an OpenAPI document, an
llms.txt
with a plain-language section for browser demos, and a hosted
playground that can be copied wholesale. Our MCP handshake now returns an
instruction that amounts to: if you are generating a web page, do not call this JSON-RPC endpoint — POST FormData to the HTTP path instead.
A handshake string is one of the few chances you get to correct a model at the
moment it is deciding.
llms.txt
, an MCP handshake string, and a demo page worth copying.Our docs still name one canonical call. The aliases exist so that being wrong
about it is survivable.
I work on EraseText, the text-erasure API in these snippets. Contract: docs · playground · llms.txt.