One Knowledge Base, Four Surfaces: Pages, Graph, Search Index, and MCP The Genome of Games project publishes 1,180 records across four surfaces—static HTML pages, an interactive graph, a search index, and a Model Context Protocol (MCP) server—using a single build command that runs in 0.39 seconds with zero npm dependencies. The build writes a 1.9 MB static index that the MCP server imports, avoiding API layers and ensuring consistency between agents and crawlers. The project demonstrates a scalable pattern for serving structured knowledge bases to both search engines and AI models. Originally published on michael-kaminski.io. The Genome of Games publishes the same 1,180 records four different ways, and one command writes all four: node build.js , 0.39 seconds, zero npm dependencies. Out come 1,245 static HTML pages for crawlers, an interactive canvas graph for humans, a 129,037-byte search index for the site's own search box, and a Model Context Protocol server exposing 8 tools to agents. The decision worth copying is the one that sounds like a downgrade. The MCP server does not query the site and does not read the source data. It statically imports a 1.9 MB index that the build wrote. There is exactly one place where slugs, lineage, and adoption edges get joined, so an agent and a crawler cannot come back with different answers. The dataset is an ontology of video game mechanics — 168 mechanics, 618 games, 394 companies, 4,366 recorded links, 1962 to 2025. What the records are about does not matter here. The shape of the problem shows up anywhere a structured knowledge base has to serve both a search engine and a model. Six hand-edited JSON files under data/ are the source of truth: the feature ontology, the graph, the prose, the company registry, the site copy, and the verified outbound links. Together they are 1,312,577 bytes. The build turns that into 16,644,215 bytes of generated read surface. A 12.7× expansion, and every byte of it is disposable. | Surface | Consumer | Bytes | Per entity | |---|---|---|---| | 1,245 static HTML pages | Crawlers, humans | 14,613,203 | 11,728 / page | mcp-index.json → MCP server | Agents | 1,901,975 | 1,612 | search-index.json | The site's own search box | 129,037 | 109 | /graph/ canvas | Humans exploring lineage | data injected at build | — | The build also emits sitemap.xml with 1,245 entries, llms.txt , robots.txt , and a 404 page. The same run reports 96,843 internal links across those pages. Nothing in that list is authored. Delete the whole output directory and the next build restores it in under half a second. The obvious way to serve an agent is to put an API in front of the data and let the MCP server call it. That is the version that rots. An API layer has to re-derive the same things the page renderer derives — how a name becomes a slug, which parents count as ancestors, which adoption edges are shown. Two implementations of one join is two implementations that will disagree, and the disagreement surfaces as an agent confidently citing a URL that renders something else. So build.js writes data/mcp-index.json as a build step, and api/mcp.mjs opens with a static import of it. The serverless function holds no derivation logic at all. Its first line of real work is const { meta, families, eras, entities } = INDEX . The protocol itself is spoken by hand — JSON-RPC over POST, no MCP SDK, for the same reason there is no Stripe or Supabase SDK anywhere in the repo. initialize , tools/list , tools/call , ping , and two notifications is the entire surface area. The whole server is 21,508 bytes. You can check it from a terminal: curl -s -X POST https://genome-of-games.vercel.app/api/mcp/ \ -H 'Content-Type: application/json' \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' Eight tools come back: overview, search, get mechanic, get game, get studio, trace lineage, list family, by year. Same 1,180 entities, two indexes, wildly different budgets. The search index spends 109 bytes per entity. The MCP index spends 1,612. That ratio is 14.7×, and it is the most useful number in the build. The search box only needs enough to rank a substring match and hand over a URL: name, type, year, path. Everything else is one navigation away, and the human doing the navigating is the retrieval system. An agent has no second hop it can afford. If genome get mechanic returns a stub, the model either guesses or makes four more tool calls, and both outcomes are worse than a fat payload. So the MCP record carries the credited origin game and its developer, the full prose essay, parents, children, everything downstream, and the later adopters — pre-joined. The generic version of this: size a machine-readable surface by how many round trips the consumer can tolerate, not by what looks tidy. Humans tolerate many. Agents tolerate roughly one. The same logic drives the tool descriptions. genome get overview spends its budget telling the model that "origin" means the first notable shipped implementation rather than invention, that over-the-shoulder aim is credited to kill.switch in 2003 rather than Resident Evil 4 in 2005, and that 949 of 1,180 entities carry a verified Wikipedia permalink while the remaining 231 carry none. Coverage gaps are a tool output, not a footnote. Because mcp-index.json is committed rather than gitignored, a rebuild is a falsifiable claim. I ran the build three times: 0.40s, 0.39s, 0.39s. The index md5 was ba9928a35651fb201b4e0c3e0cab61d7 before and after, and git status reported zero changed files. That is the whole verification story. If a build ever produces a diff on a run where the source did not change, the pipeline has picked up a clock, a hash seed, or a network call, and the "one join" guarantee is already gone. Committing generated output is usually bad practice. Here it buys a cheap integrity test on every pull request, and it keeps the build offline and reproducible — the Wikipedia verification pass runs separately, by hand, and writes its results into the committed data. Single source of truth holds only for surfaces inside the build step. The README is outside it, and it is already wrong. The README says 1,243 pages and 89,351 internal links. The build says 1,245 and 96,843. Off by two pages, and off by 7,492 links — an 8.4% understatement, sitting in the first file anyone reads. Nobody edited a number badly. Content was added, every generated surface absorbed it silently, and the one hand-written surface stayed where it was. That is the failure mode this architecture produces: it does not create disagreement between surfaces it owns, and it hides disagreement with surfaces it does not. The fix is not discipline. It is either generating the README's numbers too, or asserting them in the build and failing loudly. I have done neither yet, which is why this paragraph exists. There is a second bill. The MCP function statically imports 1.9 MB, so every cold start pays for the whole dataset whether the caller wanted one mechanic or the overview. At this size that is a fine trade. At 20 MB it would not be, and the answer then is a real index with range reads — which reintroduces exactly the derivation layer this design removed. Copy the direction of the dependency. Generated artifact in, no query out. The agent surface should be downstream of the same build that produces the pages, never a sibling of it. Copy the honesty budget in the tool descriptions. A model that is told where a dataset is thin cites it more carefully than one that is handed clean-looking records. Do not copy the static import past a few megabytes, and do not copy hand-rolled JSON-RPC into a codebase that already has dependencies — the only reason it is defensible here is that the alternative was the repo's first one. If you are shipping a knowledge base to both crawlers and agents, I want to know what your per-entity byte ratio is between the two surfaces. Mine is 14.7×. I have not seen anyone else publish theirs, and it is the number that decides whether the agent has to make a second call.