Don't let agents invent your site: fetch, cache, and cite with a stdio MCP server
Ask an agent what a public site says about a product page, a topic note, or an essay. You usually get fluent prose: a stack list, a couple of metrics, a confident "this project uses X." What you rarely get is a URL you can open.
That is the failure mode. The model is not retrieving the site. It is interpolating a site-shaped answer from training data, prior chat, and whatever fragments happened to be in the prompt. Project names drift. Metrics get rounded. Pages that never existed sound real.
The fix is not a better system prompt. It is a tool boundary: the client should only claim what a tool returned, and the tool should only return text it fetched, with the public URLs attached.
site-context-mcp is a small Python MCP server that does exactly that. It speaks MCP over stdio via FastMCP, fetches a public llms.txt plus the same-host pages it links, caches them in-process (~1 hour TTL), and exposes four tools. Every response is JSON text with a top-level sources[].
This is site/project context, not an identity server. A sibling package, resume-mcp, already answers "who is this person" from resume.json. Mixing those jobs is how you get a resume tool that starts inventing product pages.
The contract: no citation, no claim
MCP is a typed tool protocol. A local process lists tools; the model calls them; the host returns structured results. The interesting part is what you put in those results.
This server treats sources as a required field, not a nicety. A helper merges the tool body with a de-duplicated URL list and json.dumps the whole thing. Error paths do the same: an empty search query, a missing page, a failed corpus load — all still include sources. If a fetch partially fails, the surviving pages are returned and the failures show up in cache_notes.
FastMCP instructions tell the host model to answer from the cached corpus and to cite those URLs. Instructions are not enforcement. The enforcement is the payload: if the snippet is not in the tool result, it did not come from this server.
Seed from llms.txt, then same-host only
On process start the server tries cache.ensure_loaded() (and again on first tool use if that failed, or when the in-process TTL expires — CACHE_TTL_SECONDS = 3600). The cache then:
-
GETs the wired host's /llms.txt (default: https://yongbo-yu.vercel.app/llms.txt)
- Parses markdown links in document order
- Fetches each allowed URL
- Always seeds home, a known
/projects/... evidence page, and /resume.json if parsing is thin
Allowlisting is the security model. A URL is kept only if it is HTTPS, on the wired host, not a binary extension (images, fonts, PDFs, archives), and not a blocked fragment (off-host social, private admin hosts, internal app paths). Redirects that leave the host are dropped. There are no API keys and no cookies.
The default corpus is one public site. The shape — llms.txt as the machine-readable index, same-host follow, in-memory TTL — is the reusable part. Change the host constant to another public site that publishes llms.txt and you get the same tools.
HTML is stripped with a small html.parser.HTMLParser: skip script / style / svg / noscript, prefer main / article over the rest of body, take
(dropping a | Site name suffix) and meta / <code>og:description</code>. JSON pages are pretty-printed so keyword search can hit field values. Plain text (<code>llms.txt</code>) is stored as-is.</p>
<p>Fetch uses <code>httpx</code> (20s timeout, follow redirects, User-Agent <code>site-context-mcp/0.1.0</code>). A <code>threading.Lock</code> serializes reloads so two overlapping tool calls cannot double-fetch. Failures append to <code>cache.errors</code> instead of aborting the whole corpus — one 500 should not erase ten good pages.</p>
<h2>
<a name="four-tools-one-response-shape" href="#four-tools-one-response-shape" class="anchor">
</a>
Four tools, one response shape
</h2>
<table><thead>
<tr>
<th>Tool</th>
<th>What it returns</th>
</tr>
</thead><tbody>
<tr>
<td><code>list_site_pages</code></td>
<td>Catalog of cached pages: path, title, URL, kind, optional project slug</td>
</tr>
<tr>
<td><code>get_page</code></td>
<td>One page by path (<code>/essays/...</code>) or absolute HTTPS URL, plus extracted text</td>
</tr>
<tr>
<td><code>search_site</code></td>
<td>Keyword matches: score, snippet (≤400 chars), source URL, path, title</td>
</tr>
<tr>
<td><code>get_project</code></td>
<td>A <code>/projects/{slug}</code> evidence page; optional light fields from <code>resume.json</code></td>
</tr>
</tbody></table>
<p><code>search_site</code> is not embeddings. It lowercases the query, splits on whitespace, scores chunks by how many tokens appear, and keeps the best snippet per page plus title/description hits. HTML is split on sentences; <code>llms.txt</code> / JSON use paragraph blocks. <code>limit</code> is clamped to 1–25 (default 8). Dedup key is <code>source|snippet[:120]</code>.</p>
<p><code>get_project</code> is still site context. It resolves a slug or display name to a cached <code>/projects/...</code> page. If <code>resume.json</code> was fetched, a short cross-link (name, description, up to five highlights, dates) is attached and <code>resume.json</code> is added to <code>sources</code>. It does <strong>not</strong> grow identity or resume-summary tools.</p>
<p><code>list_site_pages</code> always puts <code>llms.txt</code> first in <code>sources</code>, then every page URL. <code>get_page</code> cites the page plus <code>llms.txt</code>. A miss returns <code>available_paths</code> so the model can recover without guessing.</p>
<p>Example — <code>get_page("/projects/kilodock")</code>. That path is one page in the default corpus, not the subject of this post. The tool returns JSON text shaped like:<br>
</p>
<div class="highlight"><pre class="highlight json"><code><span class="p">{</span><span class="w">
</span><span class="nl">"url"</span><span class="p">:</span><span class="w"> </span><span class="s2">"https://yongbo-yu.vercel.app/projects/kilodock"</span><span class="p">,</span><span class="w">
</span><span class="nl">"path"</span><span class="p">:</span><span class="w"> </span><span class="s2">"/projects/kilodock"</span><span class="p">,</span><span class="w">
</span><span class="nl">"title"</span><span class="p">:</span><span class="w"> </span><span class="s2">"KiloDock — Gym Operating System by YongBo Yu"</span><span class="p">,</span><span class="w">
</span><span class="nl">"description"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="p">,</span><span class="w">
</span><span class="nl">"kind"</span><span class="p">:</span><span class="w"> </span><span class="s2">"html"</span><span class="p">,</span><span class="w">
</span><span class="nl">"content_type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"text/html"</span><span class="p">,</span><span class="w">
</span><span class="nl">"text"</span><span class="p">:</span><span class="w"> </span><span class="s2">"KiloDock is a multi-tenant CrossFit gym operating system independently built by YongBo Yu. ... Mobile schedule latency: 2.8–4.6s down to 683ms. Admin load: six API calls / 3.05s down to one call / 778ms. ..."</span><span class="p">,</span><span class="w">
</span><span class="nl">"cache_notes"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="p">,</span><span class="w">
</span><span class="nl">"sources"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
</span><span class="s2">"https://yongbo-yu.vercel.app/projects/kilodock"</span><span class="p">,</span><span class="w">
</span><span class="s2">"https://yongbo-yu.vercel.app/llms.txt"</span><span class="w">
</span><span class="p">]</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div>
<p></p>
<p><code>search_site("LangGraph")</code> is the more typical agent call. Each match has <code>snippet</code> + <code>source</code>; the top-level <code>sources</code> array is the union of <code>llms.txt</code> and every hit URL. The client can quote a snippet and the URL in the same breath. If <code>cache_notes</code> is a non-null list, a page was skipped — treat the corpus as partial, not complete.</p>
<h2>
<a name="install-and-wire-it-into-cursor" href="#install-and-wire-it-into-cursor" class="anchor">
</a>
Install and wire it into Cursor
</h2>
<p>Python 3.11+. Dependencies: <code>mcp>=1.9,<2</code> and <code>httpx</code>. From a clone of the repo:<br>
</p>
<div class="highlight"><pre class="highlight shell"><code>pip <span class="nb">install</span> <span class="nt">-e</span> <span class="s2">".[dev]"</span>
pytest <span class="nt">-q</span>
python <span class="nt">-m</span> site_context_mcp
</code></pre></div>
<p></p>
<p>Tests mock <code>httpx.Client</code>; they do not need live network. CI runs pytest on 3.11 and 3.12. After install, the console script is <code>site-context-mcp</code>. Equivalent: <code>uv run python -m site_context_mcp</code>.</p>
<p>The process speaks MCP on <strong>stdin/stdout</strong>. Do not print logs to stdout; they corrupt the protocol. FastMCP's default transport here is <code>stdio</code> — that is what Cursor and Claude Desktop spawn as a child process.</p>
<p>Cursor (<code>~/.cursor/mcp.json</code> or project <code>.cursor/mcp.json</code>):<br>
</p>
<div class="highlight"><pre class="highlight json"><code><span class="p">{</span><span class="w">
</span><span class="nl">"mcpServers"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
</span><span class="nl">"site-context-mcp"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
</span><span class="nl">"command"</span><span class="p">:</span><span class="w"> </span><span class="s2">"python3"</span><span class="p">,</span><span class="w">
</span><span class="nl">"args"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"-m"</span><span class="p">,</span><span class="w"> </span><span class="s2">"site_context_mcp"</span><span class="p">],</span><span class="w">
</span><span class="nl">"cwd"</span><span class="p">:</span><span class="w"> </span><span class="s2">"/absolute/path/to/site-context-mcp"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div>
<p></p>
<p>If the package lives in a venv, point <code>command</code> at that interpreter. With uv:<br>
</p>
<div class="highlight"><pre class="highlight json"><code><span class="p">{</span><span class="w">
</span><span class="nl">"mcpServers"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
</span><span class="nl">"site-context-mcp"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
</span><span class="nl">"command"</span><span class="p">:</span><span class="w"> </span><span class="s2">"uv"</span><span class="p">,</span><span class="w">
</span><span class="nl">"args"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"run"</span><span class="p">,</span><span class="w"> </span><span class="s2">"python"</span><span class="p">,</span><span class="w"> </span><span class="s2">"-m"</span><span class="p">,</span><span class="w"> </span><span class="s2">"site_context_mcp"</span><span class="p">],</span><span class="w">
</span><span class="nl">"cwd"</span><span class="p">:</span><span class="w"> </span><span class="s2">"/absolute/path/to/site-context-mcp"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div>
<p></p>
<p>Reload MCP servers. The four tools should appear under <code>site-context-mcp</code>. Then ask the client to list cached pages and cite the URLs, or to search the corpus and return snippets with sources. If the answer has no URL, it did not come from this tool.</p>
<h2>
<a name="why-this-is-enough-and-what-it-is-not" href="#why-this-is-enough-and-what-it-is-not" class="anchor">
</a>
Why this is enough (and what it is not)
</h2>
<p>This is not a crawler, not a vector store, and not a general web browser. It will not follow off-host links, will not ingest PDFs or images, and will not refresh more often than the TTL unless you restart the process (or wait out the hour). For a personal or project site that already publishes <code>llms.txt</code>, that is the right size: the index is the author's, the pages are public HTTPS, and the agent cannot silently mix in a different origin.</p>
<p>The alternative — stuffing the whole site into the system prompt — expires the moment a page changes and burns context on every turn. Caching in the MCP process keeps the corpus off the prompt until a tool call needs it. <code>sources[]</code> makes the citation path mechanical instead of a polite request in the system prompt.</p>
<p>If you already run <code>resume-mcp</code> for identity, run this beside it. One server for who someone is; one for what the site actually says. Keep the tool names honest, keep stdout clean, and refuse to answer a project claim that did not arrive with a URL.</p>
<hr>
<p>YongBo Yu (also Yong Yu) · Toronto · <a href="https://yongbo-yu.vercel.app">yongbo-yu.vercel.app</a> · <a href="https://github.com/YongBoYu1/site-context-mcp">github.com/YongBoYu1/site-context-mcp</a></p>
Top comments (0)