MemberPress AI Academy memberpress.ai

Discovery · The discovery loop

Close the MCP discovery loop on a static site

Video13 min

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.json in 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.yml and 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/list returns 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

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.