I run Bilingual Mix, a small tool that turns parallel text into sentence-by-sentence alternating listening audio (one line in language A, then the same line in language B). I recently exposed it as a remote MCP server so an AI client can build those tracks directly, and then published the endpoint to the official MCP Registry.
This is the checklist I wish I'd had: the five design decisions that actually mattered in production, followed by the exact manifest and commands that got the server listed. No SDK, no long-running process, no API key to hand out.
The shape of the server
The endpoint is a single POST https://bilingualmix.com/mcp — Streamable HTTP, stateless (no Mcp-Session-Id), running on a Cloudflare Worker. It exposes five tools:
| Tool | What it does |
|---|---|
create_bilingual_audio |
Sentence pairs → one alternating track, plus a per-sentence timeline |
align_audio |
Two public audio URLs → transcribed, semantically aligned sentence pairs |
translate_lines |
Fills in the missing language (members only) |
get_quota |
Sign-in state, minutes used/left, per-run limits |
list_plans |
Pricing and allowances |
I hand-wrote the JSON-RPC dispatch instead of pulling in @modelcontextprotocol/sdk. The Worker free tier has a 3 MB bundle budget, I only needed four methods (initialize, tools/list, tools/call, resources/list), and pinning protocol version 2025-06-18 while still accepting 2024-11-05 clients is easier when a dependency isn't quietly changing the wire format underneath you. The dispatcher is about 80 lines.
Five decisions that mattered
| Decision | Reason |
|---|---|
| Never return audio in a tool result | One minute of 24 kHz mono WAV is ~2.9 MB; base64 would blow up the model's context |
| Count quota in D1, not KV | KV is eventually consistent — I saw 401 → 200 → 401 for the same IP |
Normalize IPs to /64
|
IPv6 privacy extensions rotate addresses on every request |
| Make the free→login seam an HTTP 401 | Clients discover OAuth from the challenge, not from a prose error |
| Cap work per call at 30 pairs | 60 pairs pushed requests past the client's 60 s timeout |
1. Return a URL and a timeline, never a blob
A tool result is text that lands in a model's context. Audio does not belong there. So the tool stores each sentence's raw PCM in KV, returns a download URL valid for six hours plus a timeline of {a_start, a_end, b_start, b_end} for every pair. The timeline is the genuinely useful part: an agent can subtitle, cut clips, or write exercises from it without ever touching the bytes.
The download route is a ReadableStream: it writes the 44-byte WAV header first (the total PCM length was recorded when the job was created), then streams clips out of KV one pair at a time. Building the whole thing in a Uint8Array first would fail — a 45-minute job is ~240 MB of PCM against a 128 MB Worker memory limit. Peak memory ends up being one clip.
2. Quotas need a strongly consistent counter
My first version used KV for "runs today / minutes today". KV is eventually consistent across colos, so two consecutive requests from one IP could read different values. The symptom was infuriating: the third call was rejected, the fourth was allowed again.
The fix is a D1 table with an atomic upsert:
INSERT INTO run_counters (scope, subject, site_id, day, runs, minutes, updated_at)
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT(scope, subject, site_id, day) DO UPDATE SET
runs = runs + excluded.runs,
minutes = minutes + excluded.minutes,
updated_at = datetime('now')
scope is ip or user, day is UTC, and a cron prunes rows older than seven days. The charge happens synchronously in the same call that generated the audio, so the next request sees it. Rule of thumb: if a user must immediately see their own last write, don't put it in KV.
3. An IPv6 address is not a stable identity
Anonymous quota is keyed by the caller's IP. If you use cf-connecting-ip verbatim, IPv6 clients with privacy extensions present a fresh address on nearly every request — which means the free tier never runs out. Normalize to the /64 prefix, and unwrap IPv4-mapped addresses (::ffff:192.0.2.1 → 192.0.2.1) so dual-stack clients don't get a surprise third bucket:
export function ipKey(raw) {
const ip = String(raw || "").trim();
if (!ip) return "unknown";
if (!ip.includes(":")) return ip;
const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(ip);
if (mapped) return mapped[1];
// ...expand the address, zero-pad to 8 groups, keep the first 4
return groups.slice(0, 4).join(":") + "::/64";
}
Known limitation: a dual-stack client can still hold one IPv4 bucket and one IPv6 bucket. You can't fully merge those without accounting for shared NAT exits, but /64 normalization makes the v6 side stable.
4. Let the 401 start the OAuth flow
Anonymous visitors get 2 runs / 20 minutes a day (≤10 minutes and ≤8 pairs per run). A free account gets 4 runs / 40 minutes; members get 500 minutes a month with ≤45 minutes and ≤30 pairs per run. When the free allowance is spent, the server replies:
HTTP/1.1 401
WWW-Authenticate: Bearer resource_metadata="https://bilingualmix.com/.well-known/oauth-protected-resource"
MCP clients use that challenge to discover the authorization server, register dynamically, and open a real consent screen. The auth is OAuth 2.1 with authorization code + PKCE (S256 only), one-hour access tokens, and no API key or password ever reaching the agent.
Two details that saved me debugging time:
-
initializeandtools/listmust never 401. Only atools/callthat actually needs quota may challenge. If you authenticate discovery, half the clients fail before they can show your tools. -
Give the agent a free preview.
create_bilingual_audioacceptsdry_run: true, which returns the pair list, estimated minutes, and quota cost without generating anything or spending quota. The agent can ask "is this okay?" before committing. That single flag makes cost conversations possible.
5. Bound each call
Remote MCP calls sit behind client-side timeouts. Sixty sentence pairs pushed requests past the client's 60 s limit; thirty does not. Cap the input, error with a clear "split this into batches" message, and make the limits visible through get_quota so the agent can plan. Long content is a loop of small calls, not one heroic request.
Try it
claude mcp add --transport http bilingual-mix https://bilingualmix.com/mcp
Or add this to your client's MCP config:
{
"mcpServers": {
"bilingual-mix": {
"type": "http",
"url": "https://bilingualmix.com/mcp"
}
}
}
Then just ask: "Here are 12 lines of Japanese with English translations. First do a dry run to show the cost, then generate the audio and give me the link and timeline." Clients that only accept local stdio servers need an HTTP-to-stdio bridge such as mcp-remote; nothing on the server side changes. The full tool list, limits, and discovery endpoints are also in llms.txt, and the walkthrough shows what the audio is for.
Publishing to the official MCP Registry
A Registry record is one file: server.json at the root of a public repository. Directories that mirror the Registry pick it up on their own schedule, so this is a publish-once move rather than a dozen submission forms. Here is the exact file that passed validation for my server (also in the public repo):
{
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
"name": "io.github.zxm9988/bilingual-mix",
"description": "Remote MCP server for sentence-by-sentence alternating bilingual listening audio.",
"version": "1.0.0",
"websiteUrl": "https://bilingualmix.com/en/mcp",
"repository": {
"url": "https://github.com/zxm9988/bilingual-mix",
"source": "github"
},
"remotes": [
{
"type": "streamable-http",
"url": "https://bilingualmix.com/mcp"
}
]
}
Things worth knowing before you run the CLI:
-
nameis reverse-DNS. With GitHub authentication it must start withio.github.<your-username>/; DNS-based auth unlocks domain-based namespaces instead. -
descriptionis capped at 100 characters. My first 175-character version got a422. - A hosted server needs only
remotes. You do not need apackagesentry or an npm/Docker release if nobody installs your server locally.
Install the publisher (the releases page has builds for every platform; this one is macOS arm64), then validate, log in, and publish:
curl -fsSL "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_darwin_arm64.tar.gz" | tar xz mcp-publisher
./mcp-publisher validate server.json
./mcp-publisher login github # device flow: open the URL, paste the code
./mcp-publisher publish server.json
login github uses a device code and grants only the io.github.<you>/* namespace. Don't run logout just to check your auth state — it clears the credential you just obtained and you'll have to redo the device flow.
Verify by exact version, not by search
A successful CLI exit is not proof of a readable record. Search has a delay, and the endpoint that actually returns your metadata is the versioned one:
# exact read-back (name URL-encoded)
curl -s 'https://registry.modelcontextprotocol.io/v0.1/servers/io.github.zxm9988%2Fbilingual-mix/versions/1.0.0' \
| jq '.server | {name, version, remotes}'
# search returns records nested under .server
curl -s 'https://registry.modelcontextprotocol.io/v0.1/servers?search=bilingual-mix' \
| jq '.servers[].server.name'
Three gotchas I hit:
-
/v0.1/servers/<name>without/versions/<version>returns 404. That is not "not published". - Search results nest the payload under
server, so.servers[].nameprintsnulland looks like an empty result. - Search tokens matter:
search=bilingual-mixfinds my record,search=bilingualmixreturns zero. Try the full hyphenated name before concluding anything failed.
Finally, a Registry record is metadata, not a compatibility certification: it doesn't prove every client handles your OAuth or your tool schemas. And when you change published metadata, bump version rather than re-publishing the same one.
Resources
- Bilingual Mix MCP docs — tools, quota, client config
- llms.txt — machine-readable summary
- Public repo with server.json
- Registry record (v1.0.0)
- How the alternating audio is used
If you're building a remote MCP server that returns anything heavier than text, the short version is: stream it from a URL, keep counters strongly consistent, challenge with a proper 401, and give the agent a dry run. Everything else is polish.
Top comments (0)