MemberPress AI Academy memberpress.ai

Recipes

Build an Agent-Discoverable Public MCP Endpoint

A public, unauthenticated MCP endpoint your site exposes so AI agents can discover and query your catalog with no credentials. Pairs with a `/.well-known/mcp.json` discovery file.

Intermediate60 minBy Seth Shoultes

Overview

A public, unauthenticated MCP endpoint your site exposes so AI agents can discover and query your catalog with no credentials. Pairs with a /.well-known/mcp.json discovery file so agents find the endpoint automatically — no human passes the URL across.

This recipe ships the full discovery loop: agent visits a URL → agent fetches the discovery file → agent queries the public endpoint. Skip any step and the loop is open.

What You'll Build

A three-file pattern that closes the loop:

Plus a per-resource opt-out so site owners can hide specific items.

Ingredients

The code samples below use WordPress + MemberPress as the concrete example, but the pattern translates to any HTTP framework that accepts unauthenticated routes.

Steps

Step 1 — Add the opt-in toggle

A public surface should never be implicit. Add a single setting, default OFF.

// In your plugin's settings array
private const DEFAULTS = [
    // ... existing settings ...
    'public_catalog_enabled' => false,  // opt-in, never on by default
];

In your admin UI, render a checkbox with help text that explains exactly what the toggle does and what data is exposed when enabled.

Why default OFF: every public surface is also an attack surface. The toggle moves the decision from "admin notices and disables it" to "admin reads what it does and enables it."

Step 2 — Write a public-safe formatter

This is the load-bearing security control. Do not add a flag like is_public to your existing formatter — that's a bug magnet. Write a separate method.

class MembershipFormatter {
    public static function detail($product) {
        // Full internal data: member_count, access_rules, custom_meta, internal notes
        return [/* everything */];
    }

    public static function public_detail($product) {
        // Catalog-only: id, title, price, signup_url. No internal fields.
        return [
            'id'         => (int) $product->id,
            'title'      => $product->title,
            'price'      => $product->price,
            'signup_url' => $product->public_url(),
            // explicitly listed — never spread $product or use a blacklist
        ];
    }
}

A permission check controls who reaches the endpoint. A formatter controls what the endpoint can return at all. The formatter is downstream of authentication — even if someone bypasses your auth, public_detail() still cannot return admin data.

Whitelist, never blacklist. Listing the safe fields is robust against future model changes; blacklisting unsafe fields is fragile (when a new field is added, it leaks until someone notices).

Step 3 — Build the public endpoint

Register a route with permission_callback returning true. The endpoint speaks JSON-RPC 2.0.

register_rest_route('mp-mcp/v1', '/public-mcp', [
    'methods'             => ['GET', 'POST'],
    'callback'            => [$this, 'handle_request'],
    'permission_callback' => '__return_true',
]);

Inside handle_request, dispatch on the JSON-RPC method:

GET requests can return a small discovery JSON (site name, available tools) for agents that probe before posting.

Step 4 — Curate the public tool registry

Build a separate tool registry just for the public endpoint. Whitelist specific tools, do not filter your authenticated registry.

class PublicTools {
    public function __construct() {
        $this->tools['list_memberships'] = [
            'description' => 'List publicly-available membership plans.',
            'callback'    => [$this, 'list_memberships'],
            'inputSchema' => [/* args schema */],
        ];
        $this->tools['get_membership'] = [
            'description' => 'Get details for a specific membership.',
            'callback'    => [$this, 'get_membership'],
            'inputSchema' => [/* args schema */],
        ];
        // 2-4 catalog tools total. Nothing that touches PII or admin data.
    }
}

Each callback queries your data layer and runs results through public_detail() — never detail().

Step 5 — Add per-IP rate limiting and cache headers

There's no token to key on, so rate limit by IP. Use transients or your cache layer:

$key = 'mp_mcp_pub_rl_' . md5($ip);
$count = (int) get_transient($key);
if ($count >= 60) {
    return new WP_REST_Response(['error' => 'Rate limit'], 429,
        ['Retry-After' => '60']);
}
set_transient($key, $count + 1, MINUTE_IN_SECONDS);

Add cache headers to every successful response so a CDN can absorb traffic:

$response->header('Cache-Control', 'public, max-age=300');

Catalog data doesn't change often. 5 minutes is a reasonable default; tune as needed.

Step 6 — Add per-resource opt-out

Even with the public toggle on, some catalog items might need to stay hidden (B2B-only plans, internal pricing). Add a meta box on each catalog item's edit screen:

add_meta_box(
    'mpai_public_catalog',
    'AI Catalog',
    [$this, 'render_meta_box'],
    'memberpressproduct',
    'side'
);

Render a single checkbox: "Show in public AI catalog" (default checked). Save the value as a post meta (_mpai_public_catalog). Filter your list_memberships query to honor the meta:

'meta_query' => [
    'relation' => 'OR',
    ['key' => '_mpai_public_catalog', 'value' => '0', 'compare' => '!='],
    ['key' => '_mpai_public_catalog', 'compare' => 'NOT EXISTS'],
],

Default-checked + opt-out matches the principle of least surprise: existing items are public when the toggle flips on, but the admin has explicit control over each one.

Step 7 — Serve /.well-known/mcp.json

The discovery file. When the toggle is on, fetching /.well-known/mcp.json returns a small JSON pointing at your public endpoint.

add_action('init', function() {
    $path = wp_parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH);
    if ('/.well-known/mcp.json' !== $path) return;

    $settings = Settings::get_settings();
    if (empty($settings['public_catalog_enabled'])) return;  // 404 when off

    wp_send_json([
        'name'          => get_bloginfo('name'),
        'description'   => get_bloginfo('description'),
        'protocol'      => 'mcp',
        'version'       => '2025-06-18',
        'endpoints'     => ['public' => rest_url('mp-mcp/v1/public-mcp')],
        'auth_required' => false,
        'transport'     => 'http',
    ]);
}, 0);

Critical: when the toggle is OFF, the handler returns silently and the path 404s. The well-known file's existence is itself opt-in — it never leaks the endpoint when the public surface is disabled.

Step 8 — Verify end-to-end

With the toggle ON, run these from any terminal:

# 1. Discovery
curl https://your-site.com/.well-known/mcp.json
# → returns the JSON pointing at the public endpoint

# 2. Initialize
curl -X POST https://your-site.com/wp-json/mp-mcp/v1/public-mcp \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}'
# → returns server info, protocol version

# 3. List tools
curl -X POST https://your-site.com/wp-json/mp-mcp/v1/public-mcp \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'
# → returns your 2-4 curated catalog tools

# 4. Call a tool
curl -X POST https://your-site.com/wp-json/mp-mcp/v1/public-mcp \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"list_memberships","arguments":{}}}'
# → returns catalog data, formatted by public_detail()

Then turn the toggle OFF and curl /.well-known/mcp.json again — should 404. Confirms the off-switch works.

Variations

No /.well-known/ access? Some hosts block the directory at the webserver level. Fall back to a REST route at /your-prefix/well-known/mcp.json and document the URL for agents that don't probe the standard path.

Need authenticated tools too? Run a parallel authenticated endpoint at /your-prefix/mcp (with Bearer token or App Password auth). The public endpoint is for prospects; the authenticated one is for site owners. Same protocol, different access.

Want write tools publicly? Don't. The public surface is read-only by design. Anything that mutates state belongs behind authentication.

Tips

What This Gets You

When this is shipped, an AI agent given just your site URL can:

  1. Probe /.well-known/mcp.json and learn there's a public catalog
  2. Query that endpoint and discover what tools are available
  3. Call those tools and return structured data to its user

A prospective customer asks Claude Desktop "what plans does this site offer?" — Claude finds the answer with no credentials, no human in the loop. Your site becomes one of the first surfaces an AI agent can actually find and read.