← Project journal

How we made the MCP server fast: do the work at build time

On 9 November we moved four computations from each request to the image build. Real get_article calls went from a p95 of 1,060 ms to 131 ms.

How the build makes reads fast

The short answer.

The database behind LebaneseLegislation.com is read-only. It is rebuilt from the data files each night and baked into a container image, and nothing writes to it while it serves. So a result that is the same for every caller can be computed once, when the image is built, and read back in a fraction of a millisecond. On 9 November 2026 we moved four such results out of the request and into the build. The server, its one CPU and the answers stayed the same.

Why it was slow.

The MCP server and PostgreSQL share one container with one CPU. On 8 November an AI assistant sent 10 get_article calls within about 60 milliseconds. Six took 3.2 to 3.4 seconds each and four were refused with a 503. One call alone took about 230 ms, and almost all of it went to one question: which general repeal clauses might touch this article? That answer came from a view, a saved query that joined the graph of links between texts to itself on every call. On a copy of the data it took 214 of the 232 ms.

What the build makes now.

One SQL file, db/prod.sql, runs once when the image is built. It writes that view's rows into a table with an index. It writes a card for every law, the summary each reply carries (49 MB), and a card for every article with its wording in force already resolved (340 MB). It writes each law's lists of amending and amended texts, grouped and in order: 37,672 rows, built in 2.5 seconds. And it writes running counts of texts by kind and publication day: 23,975 rows, built in 0.1 seconds. The compressed image grew from 2.149 GB to 2.434 GB.

A count becomes a subtraction.

list_legislation returns how many texts match a kind and a period. Counting them meant reading all 103,112 texts, 26 to 40 ms every time. The build now stores, for each kind and each day, how many texts were published up to that day. A total is one stored number minus another: two index reads, 0.1 to 0.2 ms. On the live server, six such calls at once went from 243 to 290 ms each to 9 to 16 ms.

It is not a cache.

Nothing is loaded into memory when the server starts, nothing is warmed and nothing expires. The tables sit in the database like any other, and PostgreSQL reads them from disk as it needs them. They hold no new data: db/prod.sql can rebuild them from the published tables at any time, and the public dataset image does not carry them. Each table carries a version mark. The server checks the marks once, and if a table is missing or older it computes the answer the old way. So the standalone images still work on the plain database, and an out-of-date image cannot return an error.

One fix was not in the database.

For get_law, 60% of the server's own CPU went to writing JSON. Each level of nesting re-read the bytes of the level below, so a 284 KB reply was scanned once per level. The reply is now written in one pass, and a test checks it against the old encoder on 20,000 random values. With the prepared lists of amending texts, six get_law calls at once went from a p95 of 177 to 185 ms to 69 to 80 ms.

Same answers, byte for byte.

Every change had to pass one test before it merged: 622 calls, replayed against the old server and the new one, must give byte-identical replies. The set holds 322 calls across five tools, 100 more get_law calls and 200 real search questions. The SHA-256 hash of all the replies was the same before and after each change. Speed was measured at the live limits, one CPU and 6 GB of memory, with calls sent alone, six at once and ten at once.

What people got.

In the seven days before the change, real get_article calls had a median of 128 ms, a p95 of 1,060 ms (95 of 100 calls finish within it) and a worst case of 3,242 ms. For the 25 real calls in the first 12 hours after it: 13 ms, 131 ms and 199 ms. They included a burst of 10 calls in one minute, the pattern that had failed the day before. Its slowest call took 199 ms and none was refused.

What is still slow.

The first minute after a deploy. A new container has read nothing from disk yet: six searches sent one second after a switch took 104 to 298 ms, against 17 to 90 ms once warm. Large replies also take time to travel. get_law for a big code returns up to 284 KB, and seen from our test machine the whole call takes 0.4 to 0.9 seconds whatever the server does.

Sources

Connect your AI assistant