# Close the MCP discovery loop on a static site

Web page: https://academy.memberpress.ai/courses/agent-discoverable-surfaces/l5/

Lesson 1 of 7 in [Agent-Discoverable Surfaces](https://academy.memberpress.ai/courses/agent-discoverable-surfaces/), 13 min.

Video: https://www.youtube.com/watch?v=eJowlkkKZ8Q

**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:

```js
// 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:

```js
// 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:

```json
// .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:

```yaml
# _config.yml
include: [.well-known]
```

Test the loop with two curl commands:

```bash
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**

- ["Closing the Agent Discovery Loop"](https://sethshoultes.com/blog/closing-the-agent-discovery-loop.html) — 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.

Next: [Ship the MCP discovery loop on WordPress](https://academy.memberpress.ai/courses/agent-discoverable-surfaces/l7/)
