Work / Case study

Only what the site publishes: a read-only MCP server for AI agents

How this site answers AI agents: a read-only MCP server on its Cloudflare Worker, built from one public file and held to the site’s publishing rules by tests.

In brief

Problem
An AI agent asked about me had to scrape the pages or guess, and an interface for agents must never give out more than the pages publish.
What I built
A read-only MCP server on the site’s Cloudflare Worker: seven tools and Markdown resources, answering only from one public file the build writes and tests.
What changed
AI agents can search and read this site’s writing, projects and published numbers, read-only, and every deploy proves the live server answers from that build.
Status
Running now
Tools
  • MCP
  • Cloudflare Workers
  • TypeScript
  1. Build The site build Check: Strict schemas, a contract per table
  2. Read The site build Check: An unknown element fails the buildhtml.ts
  3. Guard The site build Check: Feed, links, keys, sizeguards.ts
  4. Test Node, in CI and before a deploy Check: Contract, schema, boundary, retrieval
  5. Deploy The deploy job Check: Live pages and server match the builddeploy-version.mjs
  6. Admit Cloudflare, then the Worker Check: Rate limit, POST only, this hostedge.ts
  7. Answer The server Check: Every answer passes its output schema
From a page to an answer, in 7 stages. Revised . Each stage, who does it and its check, in full: The architecture.

The problem

This site is static: pages, a feed and a sitemap, with no API. An AI agent asked about me, my availability or my work had two options: fetch the HTML and work out which part of it was the answer, or guess. Both go wrong in the same way. A page’s navigation and footer come along with its text, a number arrives without the day it runs through, and where the site says nothing about a salary or a client, an agent fills the gap.

The second half of the problem is what the site deliberately does not say. It publishes no phone number, no employer or client I have not confirmed and no draft, and Sleep Anchor’s audience numbers appear on one page only, under their own rules. The build enforces those rules for the pages. An interface for agents has to keep every one of them, or it becomes the easy way around them.

The answer had constraints of its own. The site runs as static assets on a Cloudflare Worker, and every other path had to stay static. The free plan allows 10 ms of CPU per request. And the privacy page says the site keeps nothing about its visitors, so the server could log nothing and keep nothing.

The architecture

The Model Context Protocol (MCP) is how AI agents call tools and read resources over HTTP. The server is at brentburgess.com/mcp: read-only, no sign-in, and the same for every client. An agent such as Claude connects to it, lists seven tools, and reads posts, case studies and pages as Markdown resources.

Everything it can say is in one file. Every build of the site writes mcp-data.json, public at that path, from the same sources as the pages: the published posts and case studies (the feed’s list), the projects, the site facts, the figures on the data page and the twelve data tables that page publishes, with each column’s type and description taken from the dbt contracts the tables are built against. Daily and weekly rows are kept for about a year. The text of every post and case study, and of the About, Consulting, Now, Privacy and Résumé pages, is read from the built HTML by a small, strict HTML reader and Markdown writer and split into sections at the page’s own headings, with each section’s links recorded. The file ends with its own id, the first sixteen characters of a hash of its content.

The Worker builds that file into the script, so the code and the data it answers from ship as one version. The @modelcontextprotocol/server package, pinned exactly, with zod for the schemas, serves both protocol eras statelessly: no session, one JSON body per request, and bodies over 64 KiB and JSON-RPC batches refused before any work. Each tool answers from the file in memory, returns its result as structured JSON and as the same JSON in a text block for older clients, and turns anything the agent can fix (an unknown id, a date range the wrong way round) into an error that says what to do instead. The seven tools, in the order the server lists them:

  • get_profile: who I am, where I am based, my availability, my email and profiles, and every page on the site.
  • list_projects: each project, its status, what it changed, its tools and its case study.
  • list_content: every post, case study and page, newest first, with its sections.
  • search: full-text search over everything published; each result is a section with an id to pass to fetch.
  • fetch: the full text of a post, case study, page or project as Markdown, or one section of it, by id, path, URL or slug.
  • get_site_stats: the figures on the data page, each with the last day it covers.
  • query_dataset: the rows of one of the twelve tables, filtered by date, with each column’s meaning.

Search is lexical BM25 over every section and project, with the title, heading and description weighted above the text, a bonus when the whole query appears as written, and a small map of the site’s own words for what agents most often ask, so a question put in other words still finds its section. The index is about a hundred units, and a query costs well under a millisecond.

In front of the server stands the Worker’s front door. Only /mcp reaches the script; every other path is a static asset. Anything but POST gets a 405 that says where the site is. A request for another host, or from a browser page on another site, is refused. Every answer carries the security headers the static pages get, and never a cookie or a session id. Observability is off, so nothing is logged, and a Cloudflare rule blocks an IP for 10 seconds after 20 requests to /mcp in 10 seconds.

Seven stages take a page to an answer. Every check is automated; no stage waits on me.

Stage Done by Check before the next stage
Build The site build: an endpoint writes the entries, projects, site facts, figures and tables into one file Every metrics file is parsed with a strict schema, and a table needs a dbt contract, a descriptor and its rows
Read The site build: html.ts turns each built page into Markdown sections at the page’s own headings An element or entity the reader does not know fails the build with its name
Guard The site build: guards.ts checks the finished file The entries are the feed’s items, every link to this site has a built file and anchor, no key names Sleep Anchor, no TODO marker, under 512 KiB
Test Node’s test runner, in CI and before every deploy Contract, schema, boundary, retrieval, config and performance tests all pass
Deploy The deploy job: deploy-version.mjs uploads a version tagged with the commit and sends traffic to it The live pages and file match the build byte for byte, the live server reports this file’s id, and the Worker logs nothing
Admit Cloudflare, then edge.ts at the front of the Worker 20 requests per 10 seconds per IP, POST only, this host only, security headers on every answer
Answer The server: one of seven tools, or a Markdown resource, from the file in memory Every answer passes its tool’s output schema, and an unknown id is an error that lists what exists

The tests run after every build, in CI, in the nightly build and in the deploy build, and each one checks a different promise.

  • Contract. Every tool and resource, through the official client in both protocol eras, against values worked out again from the sources: the site facts and projects imported, the metrics files, the Markdown entries and the built pages read from disk.
  • Schema. What agents see before they call anything, every tool’s name, description and schemas and the server’s instructions, held in a snapshot so any change shows up in review, and kept to a subset every client handles: no references, every object closed, every property described, every input bounded.
  • Boundary. Every tool called with every value its inputs allow and with every id from every listing, and every resource read, in both eras. No answer may hold a draft, a Sleep Anchor key, a TODO marker, a phone number, a 32-character id, a personal mailbox, a link to this site without a built file and anchor, a link elsewhere that no page makes, or more than 100 KB.
  • Retrieval. A set of plain-language queries with the sections they should find; a wanted section must be in the first three results for at least 90 percent of them, with a mean reciprocal rank of at least 0.75.
  • Config. The server’s code may import only its own files, the server package and zod, and may not log, read a clock, draw randomness or call the network. The Worker’s settings serve only /mcp from the script, log nothing and bind nothing but the assets.
  • Performance. 300 mixed requests after a warm-up, against the 10 ms limit: a median under 1.5 ms and a p90 under 3 ms, with startup under 250 ms. CI runs it; the nightly and deploy builds do not, so a slow runner cannot block a deploy for nothing.

Decisions and trade-offs

One public file, built with the site

Chose: the server answers only from mcp-data.json, written by every build and public at that path.

The alternatives were to read the pages at request time, or to keep a database that the pages and the server both read. One file built beside the pages means that what the server can say is exactly what one build published, no more. The tests run against the same file the Worker ships, and the live server reports the file’s id, so a deploy can prove which data is answering. The cost is freshness: an answer is as old as the last deploy. The nightly pipeline commits new numbers and the deploy workflow puts them live after it, so the numbers are a day behind at most, which is what the data page shows too.

The text comes from the built page, not the source

Chose: an agent reads what a person reads: the HTML of the built page, turned into Markdown.

The Markdown source is not what is published. The renderer turns straight quotes into curly ones and images into their alt text, tables get a wrapper, and the layout adds the brief and the project’s status. Reading the built page means the server and the page can never disagree. A reader for it has to be strict: an element or entity it does not know fails the build with its name, so new markup is handled on purpose rather than coming out garbled. The cost is that a new element in an entry stops the deploy until the reader is taught it.

The boundary is tested, not described

Chose: every rule about what the site does not publish is a test that fails the build or the deploy.

The guards fail the build before anything is deployed. The boundary tests then make over a hundred calls per protocol era and read every resource, and fail on anything that crosses the line. The server’s instructions tell an agent the same thing in words: if no tool returns something, such as a phone number, a salary, an employer or client the pages do not name, or Sleep Anchor’s audience numbers, say the site does not publish it rather than guessing. The cost is the one a boundary always has: a new table or field means updating the privacy page first, and sometimes a test.

Stateless, POST only, nothing kept

Chose: the server keeps no session, sets no cookie, logs nothing, and leaves abuse to Cloudflare.

Both protocol eras are served without a session. The Worker’s observability is off, the code may not call the console (a test reads every file of the server for it), and the deploy fails if the Worker’s settings turn logging, Logpush or a tail consumer on. Abuse is handled before the script does any work: the Cloudflare rule blocks an IP after 20 requests in 10 seconds, and the handler refuses bodies over 64 KiB and JSON-RPC batches, which an older client could use to make one small request do the work of a hundred. The cost is that I cannot see how the server is used beyond Cloudflare’s request totals, which is what the privacy page promises.

Under 10 ms of CPU, on the free plan

Chose: everything that can be done once is done while the Worker starts, not on a request.

The SDK wants a new server for every request and converts every tool’s schemas to JSON Schema on every tools/list, about 3 ms each time on this catalog. The schemas are now converted once per isolate and copied. The documents, the search index and one server are built at startup, where the Worker has its own budget of one second, and each tool runs once there, so the first real request pays nothing it can avoid. The performance test holds the line at a median under 1.5 ms and a p90 under 3 ms. The cost is that the file is compiled into the script, so it has a size limit of 512 KiB, and the pull request test asks for a quarter of that free for the nightly numbers to grow into.

Lexical search, with a golden set

Chose: BM25 over sections, not embeddings, held to a set of plain-language queries.

About a hundred sections and projects, a query that costs well under a millisecond, no model to call, and the same answer every time. The retrieval test holds queries written the way a person asks, each with the sections it should find, and requires a wanted section in the first three results for at least 90 percent of them. The cost is that words are matched, not meaning: a synonym the site does not use has to be added by hand to a small map, and the golden set guards only the queries it lists.

What the AI did, what I decided, what caught it

An AI coding assistant, Claude Code, wrote the code to my design, and each piece has a check behind it.

  • I decided the shape: read-only, one public file, nothing kept, and the publishing rules as tests. Claude Code wrote the server, the reader, the guards and the tests.
  • A snapshot of what agents see, every tool’s name, description and schemas and the server’s instructions, is checked on every test run, so a change to it is reviewed on purpose.
  • The config test reads every file of the server for what it may import and call.
  • CI bundles the Worker as a deploy would and fails if anything in it uses eval, which Workers refuse.

Where it breaks

  • Freshness. An answer is as old as the last deploy, and the numbers run through the day the nightly last collected, which every answer states.
  • New markup. An element the reader does not know stops the deploy, by design, until the reader learns it.
  • Words, not meaning. A query in words the site does not use can miss, and the golden set guards only the queries it lists.
  • The first request on a fresh isolate. The SDK compiles its request path then, and the Workers runtime refuses to run a request at startup, so that cost stays; Cloudflare allows an isolate an infrequent overrun of the 10 ms.
  • One address, many agents. The rate limit counts per IP, so agents behind one address share 20 requests per 10 seconds and are blocked for 10 seconds after that.

What changed

  • An AI agent asked about me can now connect to brentburgess.com/mcp with no sign-in and get the answer from the site itself: who I am and my availability from one tool, a section of a case study from a search and a fetch, and a number with the last day it covers. Before, it had the HTML or a guess.
  • Every build publishes the data an agent can read as one file with an id, and every deploy proves that the live pages, the live file and the live server are that build’s, byte for byte and id for id.
  • The site’s publishing rules hold for agents as they do for people. The same loader drops drafts, the same list feeds the feed and the server, Sleep Anchor’s numbers stay on their own page, and the boundary tests make over a hundred calls per protocol era to prove it.
  • Nothing new is kept about anyone. The server has no session, no cookie and no log, and the deploy fails if that changes.
  • Agents can find it: it is listed in the official MCP Registry as com.brentburgess/site. The server is also open source as a template for any personal site, with its contract, boundary and retrieval tests running against an example bundle.

What I would carry into any data platform

  • Build the data an interface serves as one artifact, with the code, and give it an id the live system reports.
  • Read from what is published, not from what was written.
  • Test the boundary, not the description of it, and make the test call everything.
  • Pin exactly, convert once, warm at startup, and measure against the limit you actually have.
  • Fail the build on the unknown: an element, an entity, a key, a link.