/user/kayd @ devops :~$ cat blog-mcp-server-netlify.md

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

QR Code linking to: I Gave My Blog an MCP Server (and You Can Too)
Karandeep Singh
Karandeep Singh
• 6 minutes

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.

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:

{{- $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:

PrimitiveWho drives itWhat it’s for
ToolsThe modelActions the model decides to call
ResourcesThe client/userAddressable context you attach directly
PromptsThe userReusable 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:

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.

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:

### [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:

{
  "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" }
  ]
}
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

Question

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

Similar Articles

More from devops

Knowledge Quiz

Test your general knowledge with this quick quiz!

A set of multiple-choice questions to test your knowledge.

Take as much time as you need.

Your score will be shown at the end.