1. What you are cloning
The thing behind https://mcp.ainumbers.co/mcp is a single Cloudflare Worker. It speaks MCP over streamable HTTP, registers several hundred tools, and answers tools/call by running a deterministic JavaScript kernel over the arguments it was given. Tool definitions and kernels are not authored inside the Worker: they are generated from a catalog file that lives in a separate repository and are copied in as static files before deploy.
What it is not, and this shapes every decision below:
- Not a hosted SaaS. There is no signup, no tenant, no per-user state. Every request is answered from static data plus pure computation.
- Not a build-step app. The cloud build does not compile the catalog. Generation runs on a developer machine and its output is committed. The deploy pipeline ships committed files only.
- Not backed by a database. Discovery responses and the search index are files served through the Worker's static assets binding.
- Not stateful across requests. The transport is stateless streamable HTTP: a new transport per request, no session to resume.
If your catalog is small, most of what follows is overkill. The interesting parts start at roughly one hundred tools, where per-request schema construction and bundle size both stop being free.
2. The two-repo shape, and why
Two repositories, deliberately:
| Repo | Default branch | Holds |
|---|---|---|
PostOakLabs/ainumbers | main | Source of truth. The catalog (chaingraph/chaingraph.json), every kernel module, tool manifests, and the static site itself. Zero npm dependencies. |
PostOakLabs/ainumbers-mcp-apps | master | The deployable Worker: worker.mjs, wrangler.jsonc, CI, and the vendored copies of everything the Worker needs at runtime. |
The split exists because the two halves have opposite dependency profiles. The site repo is a zero-dependency static estate that must stay installable-free and auditable. The Worker repo needs the MCP SDK, Wrangler, and a dependency bot keeping them current. Keeping them in one repository would push npm into the site, or push a build step into the pages.
The cost of the split is a copy step, which is the single largest source of outages in this design. Section 3 is that copy step, and section 4 is the gate that catches its worst failure.
3. The generation step, and the rule that prevents outages
A script in the Worker repo, generate.mjs, reads the site checkout and writes into the Worker repo:
data/chaingraph/chaingraph.json, the catalog the Worker registers tools from.data/tools/anddata/manifests/, the widget HTML and manifests for the tools that ship a UI.data/kernels/, kernel modules served over HTTP through the assets binding.kernels/, the same modules again at the repo root, because these are statically imported byworker.mjsand therefore bundled into the Worker by Wrangler.- Precomputed discovery responses and a search index, covered in section 5.
It resolves the site checkout explicitly, in this order: a --repo= flag, then the AINUMBERS_REPO environment variable, then ../repo relative to the invoking shell's working directory. Deriving that path from the script's own location was tried and removed: run from an isolated worktree, it silently resolved to the wrong checkout and writes were discarded.
# from the Worker repo, with the site checkout beside it
node generate.mjs --repo=/path/to/ainumbers
generate.mjs, and commit data/ and kernels/ in the same push. The cloud build cannot run generation, because the sibling site checkout does not exist there. A push that changes the catalog without the regenerated output deploys a stale or unresolvable bundle. This one rule accounts for the recurring class of "the server went down right after a merge".Two gates make the rule enforceable rather than aspirational. A dry-run bundle fails if any static import cannot resolve in the committed tree, which catches the case where kernels/ was never committed. A vendor-freshness check re-reads the site repository at HEAD in CI and compares it semantically against the committed vendored copies, which catches the case where they were committed but are stale.
4. Unique tool names, or the handshake dies
The MCP SDK throws when the same tool name is registered twice. That throw happens inside server construction, so it does not break one tool: it returns a 500 for initialize and the entire server becomes unusable to every client. A bundle can be perfectly valid and still do this.
So name uniqueness is a build gate, not a convention. The check reads the vendored data, meaning what will actually deploy, and asserts a single namespace across three sources:
- every live catalog node's tool name,
- every widget tool's name, taken from its manifest's tool definition or falling back to its slug,
- the fixed set of utility tools, imported from the one module that defines them rather than re-listed in the checker.
node generate.mjs --repo=/path/to/ainumbers
node scripts/check-tool-names.mjs # exit 1 on a fatal collision
Two details worth stealing. First, that utility list used to be a second hardcoded copy inside the checker and it drifted, so a new utility tool could ship having never been collision-checked; importing from the producing module removes the possibility. Second, not every collision is fatal: a widget and a catalog node that are deliberately the same tool resolve through a dedup in the Worker, so the checker exits zero and warns, rather than blocking a legitimate pairing.
5. Cloudflare workarounds
Exactly one deployer
Cloudflare's native Git integration (Workers Builds) and a GitHub Actions deploy job will both fire on the same push, producing two versions seconds apart. The Workers Build is ungated, so it can ship a bundle that the Actions pipeline would have rejected. This is not theoretical: it was decided once, regressed when the integration was reconnected, and took the endpoint down.
wrangler deploy against production, for the same reason: it bypasses every gate.The rate-limit rule turns smoke tests into skips
A WAF rate-limit rule protects the /mcp path (the Playground states the live figure: 50 requests per 10 seconds per IP). Any smoke test that sweeps the whole catalog exceeds that in a burst, and the responses come back as HTTP 429 or 503. The trap is how those surface: a sweep script that treats a non-answer as "this vector could not run" reports them as skips, not failures, and a run full of skips still exits zero.
Three mitigations, all of which the real scripts use:
- Pause between calls and retry with backoff before counting anything as skipped. The sweep exposes both as environment variables,
MCP_SMOKE_THROTTLE_MSandMCP_SMOKE_RETRIES, so a rate-limited run can be re-run gently rather than re-interpreted. - In CI, retry any single assertion that lands in a saturated window. The count-drift step that compares the live tool count against the committed one retries past the rate-limit window, because a 429 parsed as zero tools reads as a catastrophic drift.
- Read the skip count, not just the exit code. Zero failures is the green signal; a run that is mostly skips proved almost nothing.
Fitting hundreds of tools inside a free-plan CPU budget
Registering several hundred tools means the SDK converts every tool's schema to JSON Schema. Doing that per request on a cold isolate exceeds the per-invocation CPU limit and Cloudflare returns error 1102. Three structural fixes, in order of how much they buy:
- Precompute discovery. The
initialize,tools/list,resources/listandprompts/listresponses are immutable for a given deploy. They are captured once at generation time by driving the real server through an in-memory transport at the raw JSON-RPC layer, then served as static bytes. The server is never rebuilt for discovery. - Build one tool for a call.
tools/callbuilds the server with an "only this tool" option, skipping schema construction for everything else. A CI gate asserts that the single-tool build registers that tool byte-identically to the full build, so the optimization cannot silently change a definition. - Serve large data as assets, not bundle. The search index is measured in megabytes and lives under the assets directory, served through the assets binding with
run_worker_firstset, rather than being imported into the bundle. Re-parsing a large list on the cold-start hot path was itself an outage cause and is now blocked by a static invariant check.
McpServer in module scope across requests produces "Already connected to a transport" and a 500 on the second request into a warm isolate. Construct per request. A gate greps for the pattern deterministically, because a local dev server does not reproduce the isolate reuse that triggers it.Request-shape quirks
GET /mcpanswers 405. The streamable HTTP transport permits a server to refuse the optional server-to-client stream. Returning 405 with anAllowheader lets the standard client proceed immediately; leaving the GET open instead makes clients appear to hang before timing out.- Read the body once. A syntactically invalid JSON POST used to hang until a guard timeout, because the handler consumed the request body to inspect it and the transport then tried to re-read the same drained stream. Fast-fail with a typed parse error before the transport is touched.
- Answers arrive as server-sent events. Clients must accept both
application/jsonandtext/event-stream, and a script consuming the response should stream it and resolve on the first message matching its request id rather than waiting for the stream to end.
6. CI gate order
One validation job runs on every pull request and every push. A separate deploy job runs only on a push to the default branch and only if validation passed. The order that matters:
- Dry-run bundle.
wrangler deploy --dry-runbundles the Worker without deploying it, failing if any static import cannot resolve in the committed tree. - Tool-name collision check. The gate from section 4.
- Worker hot-path invariants. Static checks for the specific patterns that caused past outages: caching the server object, re-parsing large lists on the cold path, eager asset loading.
- Kernel coverage. Every catalog node that claims server-side compute must have a matching kernel in the vendored set, and in strict mode every vendored kernel must have a node.
- Chain validation. Named multi-step workflows are checked structurally, then a corpus run executes every fixture-backed one end to end and asserts run-to-run determinism.
- Vendor freshness. The site repository is checked out in CI and the committed vendored copies are compared against it semantically, so a stale catalog cannot deploy.
- Deploy, then post-deploy smoke: a real
initializeagainst the live endpoint, followed by a live tool-count assertion against the committed count.
initialize returning 200 with a result from the live endpoint after the deploy. Treat the post-deploy smoke step as the deploy gate, and roll back on its failure rather than investigating with the endpoint broken.A second gate worth copying if you run local pre-push checks: a parity check that fails CI when the CI job runs a gate the local pre-push script does not. Without it the two drift, and "green locally" stops predicting "green in CI".
7. Verify your own clone
The minimal call. Substitute your own endpoint:
curl -sS https://your-worker.example.com/mcp \
-H 'content-type: application/json' \
-H 'accept: application/json, text/event-stream' \
-H 'mcp-protocol-version: 2025-06-18' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{
"protocolVersion":"2025-06-18",
"capabilities":{},
"clientInfo":{"name":"clone-check","version":"1.0.0"}}}'
A healthy server returns a JSON-RPC result carrying serverInfo and protocolVersion, possibly framed as a single server-sent event. Anything else, a 500, an HTML error page, or a stream that never produces a message, means the server threw during construction. Check for a duplicate tool name first.
Then confirm the catalog actually registered:
curl -sS https://your-worker.example.com/mcp \
-H 'content-type: application/json' \
-H 'accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'
Compare the returned count against the number your generation step recorded. A mismatch means the vendored data is stale, which is section 3 reasserting itself. Note that tools/list alone is a weak check: it proves registration, not that any tool runs. Call one real tool before believing a deploy.
The MCP Playground does all three steps against this deployment in a browser, with the raw request and response visible and a copyable client-config snippet, which makes it a reasonable reference for what the wire traffic should look like.
8. What to expect to get wrong first
- Committing a catalog change without the regenerated vendored output. Add the freshness gate before you need it.
- Letting two deployers run. Check this the moment an endpoint misbehaves after a merge.
- Reading a rate-limited sweep as a pass. Skips are not passes.
- Believing a green build. Only a live
initializeproves the handshake. - Caching the server object to "save CPU". It is the fastest route to a 500.