A few months ago I wrote an explainer on MCP servers: what the protocol is, why it exists, how the client-server contract works. Theory. Then last week I asked Gemini to review one of my own articles and it told me it couldn't read the page. Bot protection, it said. My blog, the one where I write about agentic systems, was invisible to agents.
That stung enough to do something about it. An assistant needed to reach the site, find the right article, and receive only material I intended to publish. The first two led to three read-only tools. The third exposed a bug that let my test suite publish fake articles to production.
Readable first, usable second
Before writing any MCP code, I had to fix the boring layer: agents that just want to read.
The reading problem sent me through the whole request path, including the CDN. Checking the origin alone wasn't enough: an agent reaches the edge configuration first. The useful debugging question was what response the caller actually received, with its headers and user agent, rather than what I expected my server to send.
With that understood, the reading layer took an afternoon:
An llms.txt file at the root, a curated markdown map of the site, generated from the live article list so I don't have a second catalog to maintain. Content negotiation next: any article URL on this site now returns raw markdown when you request it with Accept: text/markdown. That one was almost free because the articles already live as markdown in Postgres; the HTML you're reading is a render, not the source. Skipping the render for agents means no nav, no chrome, no wasted context window. The response also carries an X-Markdown-Tokens estimate that a client can use when budgeting context.
And a content signal in robots.txt: search=yes, ai-input=yes, ai-train=no. Index me, cite me, don't train on me. Whether crawlers honor it is another question, but an explicit preference beats silence.
Reading is not using
Those changes make pages easier to read, but discovery still depends on the caller: an agent finds my article if a search surfaces it or a user pastes a URL. An MCP server flips the direction. Someone adds this blog to their Claude or Cursor config, and their assistant gains tools it can call on purpose: search my articles by meaning, pull one as markdown, browse the catalog. The blog stops being pages and becomes a queryable source.
For a personal blog, the likely audience is small. Nobody is rushing to mount random blogs into their agent stack. I built it anyway, partly because I write about this exact technology and my own site should demonstrate it, and partly because the marginal cost turned out to be embarrassingly low. The hard parts already existed.
Retrieve the right content
Three tools, and every one of them is a thin wrapper over something the site already did.
search_articles reuses the site's search service. That keeps the MCP interface small, but it also means the search implementation can change underneath it. The original path combined BM25 keyword search with a pgvector HNSW index over Gemini embeddings. In hybrid mode, an article had to match the keyword side; vector ranking could improve its position but could not introduce a new result. Reciprocal rank fusion, with k set to 60, combined positions in the two lists without pretending their raw scores meant the same thing.
The current code has a second path when the local embedding service is configured. It searches article chunks using EmbeddingGemma embeddings and PostgreSQL full-text ranking, merges both candidate lists with reciprocal rank fusion, and returns each article once. A semantic match can now qualify without a keyword match, provided its similarity reaches the configured 0.60 threshold. That threshold is a retrieval setting, not a probability that the result answers the question. Without the local service configured, the earlier Gemini/BM25 path remains available.
For a reader asking about "running models at home," allowing a semantic result could help find an article titled "self-hosting LLMs." That is an illustrative reason to allow vocabulary differences, not a measured improvement from this project. The two paths make different candidate-selection decisions; the MCP tool name alone cannot tell you which one ran. The saved sessions below preserve the behavior at capture time, not a fresh evaluation of the current search configuration.
The shared retrieval code also checks each indexed source against the current published corpus and its content hash. An edited or withdrawn article's old chunks become ineligible while reindexing catches up. This matters because a fast answer from an obsolete copy is still an obsolete answer.
get_article returns a full article as markdown. It shares the exact composer function the Accept: text/markdown endpoint uses, so both paths use the same formatting rules. One token-discipline detail I'd flag for anyone building similar tools: the full article body goes in the text content only, and the structured metadata deliberately excludes it. The first version serialized the body into both, duplicating the article in the tool response. Whether a client passes both fields to its model determines the token cost, but sending the body twice creates avoidable waste.
list_articles is a SELECT with an optional tag filter. Not everything needs commentary.
Missing articles and validation failures handled inside a tool come back with isError: true. That gives the client a readable explanation it can pass to the model, such as "no published article with slug 'gpu-allocation'," so the assistant can try a search. An unknown tool or JSON-RPC method is a protocol error instead. Keeping these failures distinct helps the caller decide whether to change its request or fix its integration. The MCP tool specification describes these separate error layers.
The playback below shows saved agent runs using the shared blog-search tool, followed by an answer based on the retrieved articles. These captures demonstrate retrieval-backed behavior. They are historical captures, separate from the MCP transport session on the Agents page:
Previously captured blog-search sessions. Follow the answer back to the retrieved material. The separate narrated client recording on the Agents page demonstrates the public MCP transport.
Published content can still be the wrong content
Filtering out drafts sounds like enough until the wrong material gets marked as published. I found that out while testing this server.
While verifying all this, a routine git status showed article folders I never wrote: my-post, new-article. Content: "Title for my-post. Body text." Test fixtures. My backend tests mock the database but not the disk-sync helpers, so every pytest run had been quietly writing fixture articles into the real data directory. My deploy script synced them to the server. And the seeder, which treats on-disk articles as a backup to restore into the database, dutifully ingested and published them. Two fake articles, live on the public site, in the sitemap, in llms.txt, and served by the brand-new MCP server to any agent that asked.
The fix had two parts. Delete the junk everywhere, obviously. But the real fix was in the test harness: the suite now chdirs into a throwaway sandbox with the prompt templates available through symlinks, so fixture writes into that temporary data directory are removed at teardown. Mocking the database and forgetting the filesystem is an easy blind spot: the DB was the "source of truth," so that's where all my mocking attention went, while the actual leak was a helper writing backup files nobody thought about. A database mock cannot protect a publishing directory from filesystem writes. The isolation has to cover both.
Protocol and setup notes
My first instinct was to reach for the official MCP SDK. I didn't, and I think that was the right call for this shape of server.
For this stateless, tools-only implementation, I used JSON-RPC POST requests with plain JSON responses. The Streamable HTTP specification permits that response format and makes session IDs optional. This implementation handles five methods:
initialize → capabilities, serverInfo, version negotiation
notifications/initialized → acknowledge with 202, no body
ping → {}
tools/list → the tool catalog with JSON Schema inputs
tools/call → run one tool
GET and DELETE return 405: there's no stream to open and no session to kill. The whole transport is one FastAPI route, a small module with explicit request dispatch. It plugs directly into my existing connection pool, my existing rate limiter, and my existing test fixtures, which made direct integration convenient. Owning the route also leaves me responsible for following protocol changes.
The part worth being careful about is version negotiation in initialize: echo the client's protocol version if you support it, otherwise answer with your latest and let the client decide. Get that wrong and well-behaved clients disconnect politely, and you'll stare at logs wondering why.
Discovery breadcrumbs
The site advertises its endpoint through llms.txt and /.well-known/mcp/server-card.json. The agent-skills index also pins the SHA-256 of its skill file, with a test that recomputes the hash. These are discoverability aids; the actual compatibility check remains a client initializing the endpoint and calling its tools.
Try it
The public endpoint is read-only and requires no authentication. The inspected route sets a limit of 60 requests a minute:
claude mcp add --transport http latentsource https://thelatentsource.com/mcp
Then ask your assistant what this blog thinks about self-hosting LLMs and check whether it calls search_articles and reads the relevant result. The endpoint is https://thelatentsource.com/mcp; the card at /.well-known/mcp/server-card.json has the details.
Reproduce the protocol without the private site
The website repository is private. The public companion extracts the MCP route into a runnable example with synthetic articles, local keyword search, and protocol tests. Its adapters are deliberately simpler than production. Running the companion verifies the integration pattern; it does not reproduce either of the blog's retrieval paths.
A source ZIP is also available. Start with initialization and tools/list, then call each tool with valid and invalid input before connecting a model. That sequence separates a protocol or retrieval failure from a model choosing the wrong tool.
The public service is read-only, but search still consumes resources, including database work and query embeddings. Rate limiting belongs at that boundary. The useful design constraint for this build was reusing the search and Markdown code the site already needed, while keeping publication and administration outside the tool catalog.
What counts as working
Hand-rolling a protocol has an obvious failure mode: you build something that satisfies your own tests and no actual client. The test surface therefore includes initialization, version negotiation, tool discovery, invalid arguments, and published-content access. I also check the public endpoint through the edge. Draft exclusion must hold for article retrieval and search, not just for the homepage.
The checked-in implementation supports protocol versions 2024-11-05, 2025-03-26, and 2025-06-18. Those are the versions accepted by its negotiation code; listing them does not establish complete compatibility with every client or the older HTTP+SSE transport. A client that requests an unsupported version receives the server's supported version and must decide whether to continue.
The narrated recording on the Agents page captures a real browser MCP client using the public endpoint. It initializes, discovers the tools, lists articles, searches for MCP, and retrieves the first result. Then it deliberately requests a missing article. The saved requests and responses preserve that exchange. The video contains real transport responses and no generated answer.
The missing-article request matters as much as the successful one. It returns a tool result with isError: true, which a caller can inspect and recover from. An unknown JSON-RPC method belongs to a different failure layer. Testing both avoids turning every failure into the same generic server error.
The opening problem has a concrete answer now: an external client can discover the tools, search the blog, and retrieve a published article through the public endpoint. The fixture incident explains why that success is only part of acceptance. I also need to check that a draft stays unavailable and that the published catalog contains what I intended. The useful test starts outside the CDN, follows a result all the way to its source text, and checks the failure paths. A clean protocol response is valuable only when it delivers the right content.
