# I Gave My Blog an MCP Server (and You Can Too)

- URL: https://karandeepsingh.ca/posts/blog-mcp-server-netlify/
- Author: Karandeep Singh
- Published: 2026-08-09
- Tags: devops, automation
- Summary: A walkthrough of building a public MCP server for a static blog — one serverless function, a build-time index, and all three MCP primitives — plus the mistakes that cost me an hour and why it runs for free.

My blog now has an MCP server. You can point Claude at `https://karandeepsingh.ca/mcp`
and it will search my articles, read them, pull up a cheatsheet, or walk a research
workflow — without me pasting anything into a chat window.

The interesting part is how little it took: **one serverless function, no database, no
session store, and no extra hosting bill.** Here's the build, including the parts I got
wrong first.

**info:**
**Try it:** `claude mcp add --transport http kayd https://karandeepsingh.ca/mcp`
— or see the [MCP server page](https://karandeepsingh.ca/mcp-server/) for the full tool list.

## Why this is suddenly easy

MCP is **stateless at its core**: one JSON-RPC request in, one JSON-RPC response out.
No sticky sessions, no session affinity, nothing to keep in memory between calls.

That one property is what makes this a weekend project instead of infrastructure. A
stateless protocol maps perfectly onto a serverless function — which means the MCP
server can live in the same repo as the blog, deploy on the same push, and cost nothing
on a free tier. No second thing to run, no drift between two deploys.

## The architecture

The whole thing is:

```
Hugo build  ──►  /index.json        (full text, ~2 MB — already there for search)
            └─►  /mcp-index.json    (metadata only, ~47 KB — added for this)

Client ──POST──►  /mcp  (Netlify function)  ──fetch──►  those two JSON files
```

That's it. The blog was already generating a search index for client-side search, so the
data layer existed. The MCP server is a **thin read-only wrapper over files the CDN was
already serving.**

## Lesson 1: build a trimmed index

My first version read the 2 MB full-text index for everything. That works, but it's
wasteful — most calls (search, list tags, find related posts) only need titles, tags and
summaries, not every word of 106 articles.

So I added a second Hugo output format that emits metadata only:

```go-html-template
{{- $pages := where .Site.RegularPages "Section" "in" (slice "posts" "cheatsheets") -}}
{
  "title": {{ $p.Title | jsonify }},
  "url": {{ $p.RelPermalink | jsonify }},
  "tags": {{ (or $p.Params.tags slice) | jsonify }},
  "readingTime": {{ $p.ReadingTime }},
  "summary": {{ (or $p.Params.summary ($p.Plain | truncate 240)) | jsonify }}
}
```

**2 MB → 47 KB.** Full text is still fetched, but only when someone actually reads an
article. Both indexes are cached at module scope, so a warm container fetches each at
most once.

## Lesson 2: a tools-only server isn't a real MCP server

This is the mistake worth writing down. My first version exposed two tools — search and
read — and it felt thin. Functional, but boring: you searched, you got a wall of text,
and that was the whole experience.

MCP has **three** primitives, and I'd implemented one:

| Primitive | Who drives it | What it's for |
|---|---|---|
| **Tools** | The model | Actions the model decides to call |
| **Resources** | The client/user | Addressable context you attach directly |
| **Prompts** | The user | Reusable workflows you pick from a menu |

Adding the other two changed the feel completely.

**Resources** — every article is now addressable by its real canonical URL:

```
https://karandeepsingh.ca/posts/{slug}/
https://karandeepsingh.ca/cheatsheets/{category}/{slug}/
```

Using the actual public URL as the resource URI is a small decision that pays off: the
URI is meaningful, dereferenceable in a browser, and needs no translation layer.

**Prompts** — four workflows people actually want: research a topic with sources,
pull up a cheatsheet and answer a question from it, compare two technologies, or
summarise what's new. These are the things I'd otherwise type out by hand every time.

## Lesson 3: annotate your tools

Every tool on a read-only server should say so:

```ts
annotations: {
  readOnlyHint: true,
  destructiveHint: false,
  idempotentHint: true,
  openWorldHint: false,
}
```

Clients use these to decide what needs a confirmation prompt. Without them, a harmless
blog search sits behind an "allow?" dialog forever. With them, it can be trusted by
default — which is the difference between a tool people use and one they turn off.

Declare capabilities honestly too. If you don't implement subscriptions, say
`subscribe: false` rather than advertising something that will fail.

## Lesson 4: cap output, but paginate instead of truncating

My first `read_post` hard-capped at 2,500 characters to be polite to the caller's context
window. It was polite, and it was useless — an 8,500-character cheatsheet came back
chopped mid-command with no way to continue.

The fix isn't a bigger cap, it's **pagination**: return a window, tell the caller where it
ended, and let them ask for the next chunk.

```
_(chars 0–12000 of 18420)_
…more available — call read_post again with offset=12000.
```

Same protection against dumping a novel into someone's context, but nothing is lost.

## Lesson 5: keep links clickable

Early results returned bare URLs. Technically complete, practically annoying — nothing
was clickable and the model kept re-formatting titles into plain text.

Now every result is a markdown link, and each read appends a related-posts block:

```markdown
### [MSK vs Kinesis Cheatsheet](https://karandeepsingh.ca/cheatsheets/cost/msk-vs-kinesis/)
2026-08-06 · 7 min read · aws, cloud, cost

---
**Related posts**
- [Low-Cost Cloud Stack Cheatsheet](…) — _aws, cloud, cost_
```

It's a formatting detail that changes how the whole thing feels to use.

## Publishing it

`server.json` describes the server for the [MCP Registry](https://registry.modelcontextprotocol.io):

```json
{
  "name": "io.github.karandaid/karandeepsingh-blog",
  "description": "Search and read Karandeep Singh's DevOps and AWS blog: articles and cheatsheets.",
  "version": "3.0.0",
  "remotes": [
    { "type": "streamable-http", "url": "https://karandeepsingh.ca/mcp" }
  ]
}
```

```bash
mcp-publisher login github
mcp-publisher publish
```

Two things bit me here:

- **`mcp-publisher init` generates an npm/stdio package template.** Mine is a *remote*
  server, so the whole `packages` block had to be replaced with `remotes`. The generated
  `YOUR_API_KEY` environment variable was irrelevant too — a public read-only server
  needs no credentials.
- **`description` is capped at 100 characters.** My first one was rejected for being long.

Also: registry JWTs expire in about an hour, so run `login` and `publish` back to back.
GitHub login is only there to prove you own the `io.github.<user>` namespace — no
organisation or company required.

## What it costs

Nothing. The function runs on the same free tier as the blog, the indexes are static
files already on the CDN, and there's no database. The only real safeguard needed was a
conservative per-IP rate limit, because an agent stuck in a retry loop will happily
hammer an endpoint.

## Should you do this?

If you already publish a search index — most static site generators can — you're most of
the way there. The server is a few hundred lines, it ships with your site, and it turns
your writing into something an agent can actually use instead of scrape.

The bar for "a website an AI can use well" is about to be higher than "a website with
good SEO." This is a cheap way to clear it early.

## References and Further Reading

- Model Context Protocol. [Specification](https://modelcontextprotocol.io/specification).
- Model Context Protocol. [MCP Registry](https://registry.modelcontextprotocol.io).
- Netlify. [Write MCPs on Netlify](https://developers.netlify.com/guides/write-mcps-on-netlify/).
- Anthropic. [Connect Claude Code to tools via MCP](https://docs.anthropic.com/en/docs/claude-code/mcp).

**Question:** If your site had an MCP server, what would you want it to expose — search, or something more opinionated?

## Quiz

1. Why can a static blog host an MCP server on a serverless function with no session store?
   a) Because MCP requires a database
   b) Because the MCP spec is stateless at its core, so a server can be an ordinary HTTP handler with no sticky sessions
   c) Because Netlify keeps functions warm forever
   d) Because the client stores the server's state
   Answer: b. Modern MCP is stateless — one JSON-RPC request in, one response out. That means no session affinity and no session store, which is exactly what makes serverless a good fit.

2. An MCP server that only implements tools is missing what?
   a) Nothing, tools are the whole protocol
   b) Resources and prompts, the other two primitives
   c) A database connection
   d) OAuth
   Answer: b. MCP has three primitives. Tools are model-controlled actions, resources are addressable context the client can attach, and prompts are reusable user-selectable workflows. A tools-only server works, but it isn't the full protocol.


