Skip to main content
Glama
bburgess42

Personal MCP Server

by bburgess42
README.md
# Personal MCP server

A read-only, no-sign-in [MCP](https://modelcontextprotocol.io) server for a personal site. It answers only from one published JSON file, the bundle, that your site's build writes: your profile, posts, case studies, pages, projects and any small datasets you publish. It runs as a Cloudflare Worker, on its own or beside a static site.

A live example runs at https://brentburgess.com/mcp, and the write-up of how it was built is at https://brentburgess.com/work/mcp-server.

## Why

AI agents already read personal sites, badly: they scrape HTML, guess at what is current and fill gaps with whatever sounds right. This server gives them exactly what your pages publish, as clean Markdown and structured data, and nothing more. There is no database to query, no account to sign in to and no write path. If something is not in the bundle, no tool can return it, and the server's instructions tell the agent to say the site does not publish it rather than guess.

## What an agent gets

Seven read-only tools, in this order:

- `get_profile`: who you are, your availability, skills, how to reach you, and every page of the site.
- `list_projects`: your projects, filtered by status if asked.
- `list_content`: every post, case study and page, newest first, with each one's sections.
- `search`: full-text search (BM25) over every section of every document and project.
- `fetch`: a whole document or one section as Markdown, by id, path, URL, resource URI or unique slug.
- `get_site_stats`: the headline figures the bundle's optional `stats` record holds, and the datasets with their row counts.
- `query_dataset`: the rows of one dataset, filtered by date.

Every post, case study and page is also a Markdown resource under a URI scheme of your own, such as `example://writing/<slug>`.

## Design

- **Stateless.** Every request gets a new server built from the same bundle. No session, no cookie, no `Mcp-Session-Id`. GET and DELETE on /mcp get 405.
- **Both protocol eras.** Modern clients (2026-07-28) and 2025-era clients are both served, statelessly, through the official `@modelcontextprotocol/server` SDK.
- **Everything derived once per isolate.** The documents, search index and tool schemas are built from the bundle once, at Worker startup (`warm`), and shared by every request after. A request costs well under a millisecond of CPU.
- **Failures an agent can act on.** A tool failure is an `isError` result that says what to do instead: the ids that exist, the sections a document has, which tool answers a page that is not a document. A missing resource is a -32602 error naming the URI, never an internal message.
- **Nothing logged.** The code has no console output, and the Worker config turns observability off. Requests carry what someone asked their agent; a server that only reads public pages has no reason to keep that.
- **Security headers and checks.** Only POST, only for your hosts, and, when a browser sends an Origin, only your own. Every response carries `Cache-Control: no-store`, HSTS, `nosniff`, `no-referrer`, `noindex` and a `default-src 'none'` CSP. JSON-RPC batches and bodies over 64 KiB are refused before any work.
- **The data ships with the code.** The bundle is compiled into the Worker script through a wrangler alias, so code and data deploy as one version, and the server's version is `1.0.0+<bundleId>`.
- **Small dependency surface.** The code in src/ imports only its own files, the MCP server package and zod, and uses no fetch, no `node:` modules and no `process` (a test holds it to that).

## Layout

- src/config.ts: every site-specific value. Edit this first.
- src/bundle.ts: the bundle's zod schema and types, and the validator behind `npm run check:bundle`.
- src/catalog.ts: the tools' names, descriptions and schemas.
- src/server.ts: `createServer`, `createHandler`, `prepare` and `warm`.
- src/tools.ts, src/ids.ts, src/search.ts, src/resources.ts: the tool handlers, id resolution, search and resources.
- src/edge.ts, src/worker.ts: the Worker's front door and entry point.
- examples/bundle.json: a bundle for a fictional data engineer, Ada Example, at example.com.
- examples/server.json: a listing for the MCP Registry.
- wrangler.example.jsonc: the Worker config, in two setups.

## The bundle

The bundle is one JSON object. examples/bundle.json is a complete one; src/bundle.ts is the exact schema, with every limit. In outline:

- `format`: always `1`.
- `bundleId`: anything that changes when the content does (letters, digits and hyphens), such as the first 16 hex characters of a SHA-256 of the rest of the bundle.
- `site`: `name`, `url` (https, no trailing slash, the same as `siteUrl` in src/config.ts), `job_title`, `tagline`, `intro`, `location`, `availability` (`text`, `as_of` as YYYY-MM, `label`), `skills`, and `contact` (`email` and `profiles`, each a `label` and `url`).
- `index`: every page worth naming, each a `url`, `title` and `description`. `get_profile` returns it.
- `entries`: posts (`kind: "post"`, id `writing/<slug>`) and case studies (`kind: "case_study"`, id `work/<slug>`), newest first. Each has `published` and `updated` dates, a `brief` (problem, built, changed; null for a post), `links`, and the `project` it is about (or null).
- `pages`: other pages (`kind: "page"`, id `pages/<name>`), such as About or Now.
- `projects`: each with `title`, `summary`, `status` (`running`, `planned` or `published`), `status_label`, `outcome` (or null), `stack`, `url` and `case_study_url` (the URL of a case study in `entries`, or null). A project with no case study becomes a short document of its own, `projects/<slug>`.
- `stats` (optional): a flat record of headline figures, name to string, number, boolean or null.
- `datasets`: small tables, each with a `name` (lower case and underscores), `title`, `description`, `grain` (`day`, `week`, `month` or anything else), `date_column` (or null), `rows_from`, `data_through`, `url`, `columns` (`name`, `type`, `description`), `notes`, and `rows` as arrays in column order, oldest first.

Every document (entry or page) has an `id`, `title`, `description`, `url`, its `sections` and its `chunks`:

- `chunks` are the document's text as Markdown with absolute links, in order. Joined with blank lines, they are the whole text. The first may be the text before any heading (`anchor: ""`, `depth: 1`); a page's may start with its own `# Title`, which the resource view drops. Every other chunk is one section, starting with its own heading line (`## Heading`), with the heading's `anchor`, `heading` text, `depth` (2 or more) and the `links` in it.
- `sections` list the same headings, each with `url` set to the document's URL plus `#anchor`.

Ids are lower case. Keep the bundle public, at a URL of its own beside your pages, and put nothing in it that the pages do not already publish.

Check a bundle before deploying it:

```sh
npm run check:bundle -- path/to/bundle.json
```

It prints every problem as a path and a message, such as `entries.2.sections.0.url: must be https://example.com/writing/x#intro`, and exits 1 if there is any. The limits match the tools' output schemas, so a bundle that passes never makes a tool fail its own schema.

## Generating a bundle

Any static site generator can write the bundle at build time, after the HTML exists. In outline:

1. Collect the published entries and pages (never drafts), with their titles, descriptions, dates and URLs.
2. For each, take the main content of the built HTML page (not the source), so the bundle says exactly what the page says. Convert it to Markdown, make every link absolute, and split it at each h2 and h3 into chunks, using the heading ids the page already has as anchors.
3. Add the site facts, projects, any stats and datasets from the same data files the pages render.
4. Work out `bundleId` from the rest, write the file (for example dist/mcp-data.json), and run `npm run check:bundle` on it in the same build, so a bad bundle stops the deploy.

Taking the text from the built page keeps the bundle in step with what visitors see, including anything a template adds or a draft filter removes.

## Setup

Needs Node 24 or later (the tests run the TypeScript directly).

1. `npm install`
2. Edit src/config.ts: the site URL and hosts, the server's name and title, the instructions, the URI scheme, the tool wording that names you, the 405 text, the pointers for pages that are not documents, and the related words search uses.
3. Replace examples/bundle.json with your own bundle, or point the alias in wrangler.jsonc at the one your build writes.
4. Rewrite test/retrieval-golden.json with queries a person would ask of your site and the section ids that answer them, then run `npm test`.
5. After any change to a tool's name, description or schema (including your config's wording), review the change and rewrite the snapshot: `UPDATE_SNAPSHOT=1 npm test`.

To run the tests against another bundle without moving it: `MCP_BUNDLE=dist/mcp-data.json npm test`.

## Deploying

Copy wrangler.example.jsonc to wrangler.jsonc and choose a setup.

**(a) Standalone.** The active config: a Worker of its own, routed to `example.com/mcp` on your Cloudflare zone, with the bundle beside it. The rest of your site is served as it is today. If you put the server on a host of its own instead (mcp.example.com, say), add that host to `allowedHosts` in src/config.ts.

**(b) Added to an existing static-assets site.** The commented block: copy src/ into your site's repo (as mcp/src, for example), set `main` to its worker.ts, alias `mcp-data` to the bundle your build writes, and add `"run_worker_first": ["/mcp"]` to `assets`. Only /mcp then runs the script; every other path is a static asset that never does. Never widen that list: anything more puts your pages behind the script.

In both, keep `"observability": { "enabled": false }`, `workers_dev` and `preview_urls` off, and no other bindings; the config test checks wrangler.jsonc for all of it once you have one. Then:

```sh
npx wrangler login
npm run deploy
```

`npm run deploy` checks the bundle, runs the tests and then `wrangler deploy`. `npm run dev` serves it locally with `wrangler dev` (if npm skipped workerd's install script, approve it with `npm approve-scripts workerd` first).

Say on your privacy page that the server exists, what it answers from, and that it keeps nothing.

## Rate limiting

The server is cheap to run, but it is open to anyone. A Cloudflare rate limiting rule (Security, then Security rules) keeps one client from using up your Workers quota:

- When incoming requests match: URI path equals `/mcp`.
- Counting: by IP, 20 requests per 10 seconds.
- Then: block for 10 seconds.

The free plan allows one such rule. To switch the server off at once, change the rule to block every request to /mcp.

## Listing in the MCP Registry

The official [MCP Registry](https://registry.modelcontextprotocol.io) lists remote servers under a namespace you prove you own. With a domain, the proof is a DNS TXT record:

1. Copy examples/server.json and fill in your own `name` (your domain reversed, then a name: `com.example/site`), `title`, `description`, `websiteUrl` and the `url` of your /mcp.
2. Install `mcp-publisher` from the registry's releases.
3. Make an Ed25519 key pair, keep the private key outside the repo, and add an apex TXT record `v=MCPv1; k=ed25519; p=<public key, base64>` to your domain. It does not affect mail.
4. `mcp-publisher login dns --domain example.com --private-key <private key, hex>`
5. `mcp-publisher publish`

To update the listing later, raise `version` in server.json and publish again.

## Tests

`npm test` runs every test with Node's own runner, against examples/bundle.json (or `MCP_BUNDLE`). `npm run typecheck` runs `tsc --noEmit`.

- contract: every tool, resource and error through the official `@modelcontextprotocol/client`, in both protocol eras, against values worked out again from the bundle and config.
- schema: tools/list and the instructions against test/tools.snapshot.json, and every schema in the subset clients handle (no `$ref`, closed objects, every property described, every input bounded). Rewrite the snapshot with `UPDATE_SNAPSHOT=1 npm test` after an intended change.
- server-boundary: every tool called with every value it allows and every id the listings give, in both eras. Every call that should work does, no answer passes 100 KB, and no answer names an address the bundle does not hold.
- retrieval: search against test/retrieval-golden.json, needing recall@3 of at least 0.9 and a mean reciprocal rank of at least 0.75.
- config: src/ imports only its own files, the server package and zod, with no console, process, `node:` modules or fetch; only config.ts names the site; and both Worker setups log nothing and send only /mcp to the script.
- worker: the front door, with a stand-in for the assets: POST only, hosts and origins, security headers, 404 standalone.
- bundle: the validator accepts the example and reports each kind of mistake where it is.
- perf: CPU time per request against the Workers Free limit of 10 ms. Skipped unless `MCP_PERF=1`.

## Licence

MIT. See LICENSE.