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.
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:
- A public MCP endpoint at
/your-prefix/public-mcp— JSON-RPC 2.0, no auth, rate-limited per-IP, cache-friendly - A public-safe formatter — strips internal fields before data crosses the transport layer
- A discovery file at
/.well-known/mcp.json— points agents at the endpoint when (and only when) the admin opts in
Plus a per-resource opt-out so site owners can hide specific items.
Ingredients
- A web framework with REST/JSON routing (WordPress, Laravel, Express, FastAPI — anything that can register paths)
- An admin settings surface where you can add a toggle (default OFF)
- A formatter or serialization layer for your catalog data
- The ability to serve
/.well-known/<file>from your domain root (most frameworks support this directly; some need a small parse-request hook)
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:
initialize— return protocol version and server infotools/list— return your curated public tool listtools/call— validate args, call the public formatter, return the result
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
- Test the off switch. If your discovery file or endpoint responds when the toggle is off, the off-switch is broken. Test before shipping.
- Whitelist, don't blacklist. Every "exclude these fields" pattern eventually leaks. List the safe fields explicitly.
- One formatter per audience. A
public_detail()and adetail()are clearer and safer than a singledetail($scope)method. - Rate limit aggressively. Public endpoints get scraped. 60 req/min per IP is a reasonable default; tune based on legitimate agent traffic.
- Cache headers are not optional. Without them, every agent query hits your database. With them, a CDN absorbs most of it.
What This Gets You
When this is shipped, an AI agent given just your site URL can:
- Probe
/.well-known/mcp.jsonand learn there's a public catalog - Query that endpoint and discover what tools are available
- 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.