1. Close the MCP discovery loop on a static site 13 min
Lesson notes
Hook
Your blog, your brain, your skills repo are public. An AI client given the domain still gets nothing back — no path from "I know about this site" to "I have a structured catalog I can call." The corpus is public; the loop is open.
Concept
Prerequisites: a public HTTP endpoint, comfort reading a JSON-RPC 2.0 envelope ({jsonrpc, id, method, params}), a static site (Jekyll, Astro, Hugo, Next.js export — anything that ships to a CDN), and either the essay linked below or api-fundamentals l9. That l9 is the same loop on a WordPress surface; this lesson is its static-site mirror.
The three roles
Three pieces, each with one job.
- Endpoint — a URL that speaks JSON-RPC 2.0. Three methods:
initialize,tools/list,tools/call. On a static-site stack this is an edge function — Cloudflare Worker, Deno Deploy, Vercel Edge, your pick. - Formatter — code that turns site data into tool responses. Lives inside the endpoint. One function per tool:
list_posts,get_post,list_skills. - Discovery file — a literal file at
/.well-known/mcp.jsonin the static-site repo, pointing at the endpoint URL.
Skip any one and the loop stays open. Merge any two — say, the discovery file generated by the same code that serves the endpoint — and you have a bug waiting to surface.
Where the wall lives
The formatter's wall belongs in its data sources, not in its response shaping. A list_posts function that reads a public /posts.json catalog has no admin handle — it can't leak admin data because it can't see admin data. If your formatter sits downstream of a query that returns everything and filters at response time, one wrong conditional ships the admin row.
The static-site equivalent of the WordPress "default off" toggle is the file's existence. No .well-known/mcp.json in your repo → 404 → agent moves on. Authoring the file is the opt-in.
Walk-through
Endpoint. The Worker is single-purpose — JSON-RPC handler at the root, three methods, permissive CORS, 5-minute edge cache:
// workers/mcp.js
export default {
async fetch(request) {
const { method, params, id } = await request.json();
const result = await dispatch(method, params);
return new Response(JSON.stringify({ jsonrpc: "2.0", id, result }), {
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
"Cache-Control": "public, max-age=300"
}
});
}
};
Formatter. list_posts reads the catalog Jekyll already generates. No admin handle in the call chain:
// workers/tools/list_posts.js
export async function list_posts() {
const res = await fetch("https://yoursite.com/posts.json");
const posts = await res.json();
return posts.map(p => ({ title: p.title, slug: p.slug, url: p.url }));
}
Discovery file. The literal file in the repo:
// .well-known/mcp.json
{ "mcp": { "endpoint": "https://mcp.yoursite.com" } }
The shape of this JSON is a 2026-04 snapshot — verify against modelcontextprotocol.io before shipping.
Jekyll silently drops dotted directories at build time. Tell it not to:
# _config.yml
include: [.well-known]
Test the loop with two curl commands:
curl https://yoursite.com/.well-known/mcp.json
# → {"mcp":{"endpoint":"https://mcp.yoursite.com"}}
curl -X POST https://mcp.yoursite.com \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
# → {"jsonrpc":"2.0","id":1,"result":{"tools":[...]}}
Both succeed without auth, or you haven't shipped — the agent cannot carry a credential it was never given.
If you get stuck
- The discovery file 404s on production but works locally. Jekyll dropped the dotted directory at build time. Add
include: [.well-known]to_config.ymland rebuild. - The formatter has access to admin data and you are filtering it out in the response. Move the wall: point the formatter at a public catalog that doesn't contain admin data in the first place. Filtering at response time is one wrong conditional away from a leak.
tools/listreturns an empty array. The dispatcher loaded but no tool functions registered with it. Check the import paths inside the worker — silent module-load failures look like "no tools" from the outside.
Cross-reference
Module m3 l1 (Rate-limit OAuth /register for AI clients) covers what to do once the loop is closed and real traffic shows up. This lesson is the prerequisite; calibrating the rate limit for shared AI-client egress is the next failure.
Deliverable
Name all three roles for your own site — endpoint URL, formatter functions, discovery file — run both curl commands without auth, and explain why the formatter's wall belongs in the data sources, not the response shaping.
Further reading
- "Closing the Agent Discovery Loop" — the original essay that frames the three-role pattern this lesson turns into a static-site checklist.
Try these prompts
- Write the three method handlers for an MCP Cloudflare Worker — initialize, tools/list, and tools/call — that respond as JSON-RPC 2.0 with permissive CORS and a 5-minute edge cache.
- Write a list_posts formatter for the worker that reads a public posts.json catalog and returns title, slug, and url fields, and explain why this function cannot leak admin data even if its scope check is wrong.
- Write the static .well-known/mcp.json file pointing at https://mcp.example.com plus the Jekyll _config.yml line that prevents Jekyll from dropping the dotted directory at build time.