# Make your endpoint agent-discoverable

Web page: https://academy.memberpress.ai/courses/api-fundamentals/l9/

Lesson 9 of 9 in [Anthropic API Fundamentals](https://academy.memberpress.ai/courses/api-fundamentals/), 14 min.

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

Your authenticated endpoint works for users who are logged in. It does nothing for the AI agent that has never visited your site, has no token, and cannot get one — because you cannot pre-share a token with every prospect's AI client. The conceptual layer is covered in ["Closing the Agent Discovery Loop"](https://sethshoultes.com/blog/closing-the-agent-discovery-loop.html); this lesson is the builder's guide.

**Why auth-only fails**

The failure mode: you have a route that returns your membership catalog. It checks for a valid token. A prospective customer asks their AI assistant what plans your site offers. The agent hits your site with no credentials. Your route returns `401`. The agent reports back: nothing found.

Auth did its job. That is the problem.

The less obvious failure comes later, when you add a public endpoint and protect it only with an auth toggle. You route anonymous callers to a different handler, but the handler calls the same data-fetching function and the same formatter as the admin endpoint. The formatter knows everything — member counts, access rules, internal notes. The toggle says "skip the auth check." The formatter still runs in full. One misconfigured flag and you are returning admin-shaped data to an anonymous caller.

**The three layers**

The fix has three parts. All three must be present for the loop to close:

1. **A public read-only endpoint.** No token required. Per-IP rate limited (60 req/min is a reasonable floor). `Cache-Control: public, max-age=300` so CDNs and agents can cache it. Expose only the catalog reads you want public — in MemberPress AI Foundation that was four tools: `list_memberships`, `get_membership`, `list_courses`, `get_course`.

2. **A public-safe formatter.** This is the layer most builders skip, and it is the load-bearing one. The formatter strips dangerous fields — member counts, access rules, custom meta — and adds the fields that are safe and useful to a prospective caller, like the signup URL. It does this as a separate code path, not as a conditional inside the existing formatter.

3. **A `/.well-known/mcp.json` discovery file.** When the public catalog is enabled, a GET to that path returns the endpoint URL and metadata. When disabled, it returns `404` — not an empty object, a `404`. The endpoint's existence should not be discoverable when you have not turned it on.

**Walk-through**

Think of the formatter like a security door, not a locked cabinet. A locked cabinet relies on one control point — the lock. If the lock fails, everything inside is exposed. A security door is structural: even if you prop it open, the things behind it are still in separate rooms. The `public_detail()` formatter is not a toggle on the existing formatter; it is a different room. The fields it returns are listed explicitly. Admin fields are not present in the list, so they cannot appear in the output, regardless of what the auth check does upstream.

```python
# api/formatters.py  (illustrative — adapt to your ORM/serializer)
class MembershipFormatter:

    def admin_detail(self, obj):
        return {
            "id": obj.id,
            "title": obj.title,
            "price": obj.price,
            "member_count": obj.member_count,       # internal
            "access_rules": obj.access_rules,       # internal
            "custom_meta": obj.custom_meta,         # internal
            "signup_url": obj.signup_url,
        }

    def public_detail(self, obj):
        # Fields are enumerated explicitly.
        # Omission is the defense — admin fields are not present in this method.
        return {
            "id": obj.id,
            "title": obj.title,
            "price": obj.price,
            "signup_url": obj.signup_url,
        }
```

The public endpoint calls `public_detail()` directly. It does not call `admin_detail()` and strip fields after the fact. Stripping after the fact is the wrong pattern — a new field added to `admin_detail()` is live in the public response until someone remembers to add it to the strip list.

```python
# api/routes.py  (illustrative)
import os
from flask import Flask, jsonify, abort, make_response

app = Flask(__name__)
formatter = MembershipFormatter()

@app.route("/.well-known/mcp.json")
def well_known_mcp():
    if not os.getenv("PUBLIC_CATALOG_ENABLED"):
        abort(404)
    payload = {
        "name": "Example Site",
        "protocol": "mcp",
        "version": "2025-06-18",
        "endpoints": {
            "public": "https://example.com/api/v1/public-mcp"
        },
        "auth_required": False,
        "transport": "http",
    }
    response = make_response(jsonify(payload))
    response.headers["Cache-Control"] = "public, max-age=300"
    return response

@app.route("/api/v1/public-mcp/memberships")
def public_memberships():
    memberships = Membership.query.filter_by(public=True).all()
    return jsonify([formatter.public_detail(m) for m in memberships])
```

The `.well-known` JSON shape above is illustrative. The MCP spec is moving; check the current draft before shipping. The field names, version string, and transport values have all changed between drafts and will change again.

**If you get stuck**

- **You return an empty object from `/.well-known/mcp.json` when the feature is off.** An agent that receives `{}` may cache it or report "no tools available" instead of treating the endpoint as absent. Return `404` so agents know the endpoint does not exist in this state.
- **Your `public_detail()` calls the same internal query as `admin_detail()`.** The formatter strips fields at the output layer, but the query itself still fetches restricted data and runs any access-rule logic. Make the public query explicit — only select the columns you intend to expose.
- **Credentials appear in the `.well-known` payload.** The payload is public by definition. Any token, key, or internal URL in that payload is now public. The `auth_required: false` field signals that no credentials are needed — leave all credential material out of the discovery file.

**Cross-reference**

l8 (Add it to a real app) is the route this lesson extends — the public endpoint follows the same Flask pattern with the same `502` error handling convention. The conceptual framing for why agents need this shape is in ["Closing the Agent Discovery Loop"](https://sethshoultes.com/blog/closing-the-agent-discovery-loop.html).

**Deliverable**

Two observable checks. First: `curl https://yoursite.com/.well-known/mcp.json` returns a JSON payload with the public endpoint URL when `PUBLIC_CATALOG_ENABLED` is set, and returns `404` when it is unset. Second: compare the JSON output of your public endpoint against your admin endpoint for the same membership object. The public response must be missing at least `member_count`, `access_rules`, and `custom_meta` — run `diff` on the two outputs and confirm those keys are absent.

**Further reading**

- ["Closing the Agent Discovery Loop"](https://sethshoultes.com/blog/closing-the-agent-discovery-loop.html) — the conceptual layer: why the three-part loop exists, what breaks when any part is missing, and the six-point checklist for any commerce-adjacent service.
- ["What Agents Actually Need From Your API"](https://sethshoultes.com/blog/what-agents-need.html) — the caller's perspective: what response shape, error codes, and operationIds an agent caller needs to work reliably with your endpoint.

## Try these prompts

- Write the public_detail() formatter method that strips member_count, access_rules, and custom_meta from a membership object and adds signup_url.
- Write the Flask route that serves /.well-known/mcp.json when PUBLIC_CATALOG_ENABLED is true and returns 404 when it is false.
- Write the .well-known/mcp.json JSON object for an endpoint at https://example.com/api/v1/public-mcp with auth_required false and transport http.
