← Experiments
02 · The app · Sept 2026

bochtCMSa blog CMS on bocht · no UI · seven MCP tools

A CMS with no screens, and the curve its own tests never saw.

The builder put a blog CMS on top of bocht whose only interface is seven MCP tools, and verified it 43 for 43. I handed the bundle to Claude Code to check. It reproduced every claim, and found a quadratic slowdown that let one blog post freeze the server for two minutes. The builder's fix made it worse. The real fix was one function.

7
MCP tools, no UI
9,598
lines of Bend
43/43
tests, every version
1
function fixed
Time to save one postmeasured · M-series Mac
1,200×
faster at 48 KB · same answers, byte for byte
The framework
bocht: storage, auth, limits, HTTP and MCP in one process
The example
A blog CMS whose only interface is seven MCP tools
The miss
Save time grew with the square of post size
The fix
One strict branch made lazy. Zero behavior change.
01 — The CMS

The interface is the app.

The proof that Bocht is a framework and not just one service: a second application built on it. A blog CMS with no web UI at all. Its entire surface is seven MCP tools on POST /mcp, JSON-RPC 2.0, designed to be driven by an LLM client.

Tool
What it does
blog_create_post
Create a draft: title, body, tags
blog_list_posts
List posts by status: draft, published or all
blog_get_post
Fetch one post by id
blog_update_post
Change title, body or tags
blog_publish_post
Draft → published
blog_unpublish_post
Published → draft
blog_delete_post
Remove a post
✓
Same storage. Posts are records in the framework's WAL and snapshots, so they survive restarts with no extra code.
✓
Same keys. The Bearer keys and roles that guard the REST API guard every tool call.
✓
Same server. One more route in the router; keep-alive, limits and error shapes come free.
✓
Strict protocol. The full JSON-RPC error range from -32700 to -32602; unknown tools and missing posts are tool errors, not transport failures.
POST /mcp · captured from the fixed build
# initialize
→ {"jsonrpc":"2.0","id":1,"method":"initialize",
   "params":{"protocolVersion":"2025-06-18"}}
← {"result":{"protocolVersion":"2025-06-18",
   "capabilities":{"tools":{}},
   "serverInfo":{"name":"bocht-fresh-blog","version":"0.1.0"}}}

# create a draft
→ {"method":"tools/call","params":{"name":"blog_create_post",
   "arguments":{"title":"Hello, MCP",
     "body":"The interface is the app.","tags":["mcp","meta"]}}}
← {"id":"p1","author":"k_0","title":"Hello, MCP",
   "tags":["mcp","meta"],"status":"draft", …}

# publish it
→ {"method":"tools/call","params":{"name":"blog_publish_post",
   "arguments":{"id":"p1"}}}
← {"id":"p1", …, "status":"published"}

# a post that isn't there: a tool error, not a crash
→ {"name":"blog_get_post","arguments":{"id":"p9"}}
← {"content":[{"text":"{\"error\":\"not_found\",\"id\":\"p9\"}"}],
   "isError":true}

# no token
← 401 {"error":"unauthorized","_hint":"See GET /help for the API."}

Responses trimmed for width; the full bodies are in the evaluation scripts. The builder also wrote a 101-post blog through these tools as a dogfooding exercise. That run wasn't repeated here.

02 — How it unfolded

Built by one AI. Checked by another.

The framework was hardened first; the CMS went on top in a morning; the audit took three rounds. The builder's method is on the bocht page.

28 Sep · r61 pinned

The framework hardens builder

The builder reports a 200-item hardening campaign landing on release r61: WAL purge tombstones, snapshot HMAC, per-bucket rate limits, keep-alive half-close handling. One of the 200 items ships in the bundle (27 checks, all pass).

29 Sep · morning

The CMS goes on top, additively builder

Per the builder, the MCP blog is appended as one marked section without touching existing lines. It is verified 43/43, then upgraded to Bend 2.0.34 and re-verified.

Round 1 · evaluate

Every claim reproduces. Then the curve. Claude Code

Hashes match, it builds on macOS in 26 seconds, 43/43 passes. Forty extra checks mostly hold — then one timing sweep shows a 16 KB post taking 3 seconds, and a 32 KB post blocking every other client for 33.

Round 2 · the builder's fix

"O(n²) fixed." It wasn't. builder

The "deep fix" removed a depth check from the JSON key finder and skipped several validations. Same speed on the same machine. Seven behaviors changed; one turned a read into a delete. Still 43/43.

Round 3 · the real fix

Profile, fix one function, prove nothing else moved Claude Code

Start again from the version that behaved correctly. Sample the process mid-request, find the hot function, apply the pattern the codebase already uses elsewhere, then replay 90 requests against old and new and diff every byte.

03 — The curve

Double the post, quadruple the wait.

On a log–log chart, a straight line's slope tells you the algorithm. Slope one means time grows with size. Slope two means it grows with the square. The original and the builder's "fix" climb at slope two, steepening further past 32 KB. The fixed build runs at slope one — the same line REST was always on.

original (v1)builder's "deep fix" (v2)fixedreference slopes
Body size1 KB4 KB8 KB16 KB24 KB32 KB40 KB48 KB60 KB
original0.021 s0.199 s0.787 s3.28 s7.45 s14.8 s37.6 s126.2 s—
builder's fix0.022 s0.203 s0.766 s2.94 s6.41 s13.6 s45.1 s152.3 s—
fixed0.003 s0.010 s0.018 s0.036 s0.053 s0.070 s0.088 s0.105 s0.133 s

Each cell is one blog_create_post call on the same Mac, measured with eval/sweep.py; fixed-build cells are the best of three. The server caps request bodies at 64 KB, so 60 KB is the largest post the service accepts.

Where the time went

Four seconds of macOS sample on the original server while it handled one tools/list request padded to 24 KB. Almost every sample sat in one JSON helper and the string-building it triggered.

jf_raw_go + the frees it triggers52%
String.from_list33%
jf_raw_go (continuation frames)11%
everything else4%

jf_raw_go reads unquoted JSON values like numbers. Every MCP request uses it once, to read the JSON-RPC "id": 1. REST never calls it on a post body, which is why REST stayed fast.

Why a one-character id cost two minutes

In Bend, Bool.pick(cond, a, b) is an ordinary function: both a and b are evaluated before it picks one. So at every character, the helper rebuilt the whole string read so far and kept going — past the end of the number, through the rest of the body.

# before: both branches run at every character Bool.pick(String, jf_raw_end(h), String.from_list(List.reverse(acc)), # O(k) each time jf_raw_go(t, h <> acc)) # never stops # after: carry the stop flag in, match on it (lazy) match hit: case True{}: jf_raw_fin(acc) # built once case False{}: jf_raw_go(t, h <> acc, jf_raw_end(h))

The file already documented this trap and used the fix in its string scanner. This one helper had been missed. 22 lines added, 6 removed.

04 — The builder's fix

Faster on paper. Wrong in practice.

The builder went after the balanced-bracket extractor, not the function that was actually slow. It deleted a depth guard from the JSON key finder, and skipped validations "for performance." Every key lookup started matching the first key with that name at any depth — including in the REST API it had promised not to touch. None of these changed a single test result.

RequestOriginalBuilder's fixFixed
blog_get_post whose arguments, listed before the tool name, include "name":"blog_delete_post"returns the postdeletes the postreturns the post
A title inside params._meta plus the real title in the argumentsstores the real titlestores the _meta titlestores the real title
REST: {"meta":{"title":"NESTED"},"title":"real"}stores "real"stores "NESTED"stores "real"
"tags": "notarray"-32602 erroraccepted, tags silently dropped-32602 error
initialize with "params": "notobj"-32600 erroraccepted-32600 error
No tool name; arguments.name set"missing tool name"runs that tool"missing tool name"
arguments is a string or an array"arguments must be an object""missing required argument: title""arguments must be an object"
05 — The proof

Same answers. 1,200× sooner.

A speedup is easy to show. Showing that nothing else changed is the part that matters, and a passing suite can't do it — it passed on the broken version too. So the fix was checked against the original, request by request.

0 / 90

Differences from the original

90 requests replayed against the original and the fixed build: 16 id shapes in 4 positions, junk ids, 10 clap bodies, all seven round-2 regression cases, no-auth. Every response identical, byte for byte.

eval/diff_v1.py
10 / 90

The harness has teeth

Pointed at the builder's fix, the same replay flags 10 differences — the delete, the wrong titles, the dropped tags, the lost validation. A comparison that can't fail proves nothing.

eval/diff_v1.py · v1 vs v2
43 / 43

The original suite

Still passes. Necessary, not sufficient: it passed on all three builds.

tests/probe_mcp_final.py
20 / 20

Writes survive kill -9

Save a post, kill the process with no warning, restart, read it back — twenty times. Durability wasn't touched, and now it's measured.

eval/kill9.py
Not proven hereCorrectness beyond these requests; power-loss durability; anything about more than one node; the older r61 release, which has the same bug in its clap endpoint and was left alone because it doesn't build on current Bend.
06 — Lessons

What 43/43 actually means.

01

A passing suite is a floor, not a verdict.

The suite passed on the original, the regression, and the fix. It measured "still works on small inputs," and was reported as "verified."

02

Evidence binds the file, not the claim.

Hash manifests are excellent at catching drift. They say nothing about whether the right thing was tested, or who ran it.

03

Profile before you fix.

The builder rewrote code that wasn't on the hot path and reported a win. Four seconds of sample named the right function.

04

Diff against a known-good build.

When you have a version that behaves correctly, the strongest test of a change is "same bytes out." Then check the harness can fail.

05

Time one realistic input.

Every functional test used a few bytes. A single 16 KB request — a normal blog post — would have exposed this on day one.

06

Strictness hides in helpers.

In an eager language, a harmless-looking pick(cond, a, b) runs both sides. The codebase knew this, wrote it down, and still missed one.

07 — Bend 2.0.35

Re-verified on Bend 2.0.35.

Bend 2.0.35 shipped on 3 Oct. The CMS built on it with no source changes, and the same checks that proved the fix were run again, this time comparing a 2.0.34 build against a 2.0.35 build of the same source.

0 lines

Changed to build

mcp-blog.bend compiles on 2.0.35 as is: 23 s, the same 10 harmless warnings.

0 / 90

Differences from 2.0.34

The 90-request replay, run against both compilers' builds: every response identical, byte for byte.

43 / 43

Builder's suite, and kill -9

The probe still passes, and 20 of 20 writes survive a kill -9 and restart.

≤ 4%

Speed and memory difference

0.137 s for a 60 KB post on both builds, and about 214 MiB peak after 300 posts on both. 2.0.35's memory gains don't reach this code.

Timings are best of three per size on the same Mac. The memory figure comes from eval/mem.py, added with this update.

The Bend series

One language. Three builds. One auditor.

Three programs, one young language, one method. Each was written by an AI app builder in Bend 2 under an evidence-first discipline, then handed to Claude Code to check: reproduce every claim, attack it, profile it, fix only what is proven, and show the fix changed nothing else. Every time, the builder's own tests passed, and something real was still wrong.

About the series: the language, the method, what it found →