MemberPress AI Academy memberpress.ai

Courses

Agent-Discoverable Surfaces

Make your site, data, and tools discoverable by AI agents. MCP for assistants like Claude, Custom GPTs for ChatGPT, and OpenAPI as the schema that ties them together.

intermediate95m7 lessonsTaught by Seth Shoultes

What you'll learn

Why it matters

Almost no site has an agent-discoverability strategy in 2026. The conventions are emerging — .well-known/mcp.json, llms.txt, OpenAPI for GPT Actions. The site that ships these patterns first becomes the default reference. The site that ships them late becomes invisible to a fast-growing chunk of traffic.

Every pattern in this course is one we built and run on our own sites, not theoretical advice.

DiscoveryThe discovery loop

1. Close the MCP discovery loop on a static site 13 min

Lesson notes

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.

Back to contents

2. Ship the MCP discovery loop on WordPress 13 min

Lesson notes

Hook

Open the WordPress plugin directory. Search "MCP". One result, under a thousand active installs. Yoast has thirteen million. WordPress runs forty percent of the web, and the agent-discoverability layer for it is empty.

Concept

Prerequisites: you have registered a custom post type, called register_rest_route once, and read enough wp-json/wp/v2/ output to recognize what WordPress auto-exposes. This is l5's static-site loop ported to WordPress — the three roles are the same, the materials change.

The same loop, different platform

The discovery loop has three roles: a .well-known/mcp.json file, a JSON-RPC endpoint, and a public-safe formatter. l5 built that loop on a static site — Cloudflare Worker, literal file in the repo, catalog read from /posts.json. WordPress ships the equivalent of all three in core. Post types are the data model. register_rest_route is the endpoint primitive. template_redirect is the discovery file mechanism. The radio parts are on the bench. Nobody has wired them together.

Why the layer is missing

The SEO plugins that own this audience treat AI as a defensive problem — which crawlers to block in robots.txt, what to put in schema.org JSON-LD. Schema.org gets your site crawled by GPTBot, ClaudeBot, and Perplexity. MCP is what gets it queried. Different layers. Yoast and Rank Math shipped the first. Nobody has shipped the second.

Walk-through

Endpoint. One register_rest_route call, one JSON-RPC dispatcher, three methods:

// includes/mcp-routes.php
add_action('rest_api_init', function () {
    register_rest_route('your-prefix/v1', '/mcp', [
        'methods'             => 'POST',
        'callback'            => 'mcp_dispatch',
        'permission_callback' => '__return_true',
    ]);
});

function mcp_dispatch(WP_REST_Request $request) {
    $body   = $request->get_json_params();
    $method = $body['method'] ?? '';
    $id     = $body['id'] ?? null;
    $result = match ($method) {
        'initialize' => ['protocolVersion' => '2025-03-26'],
        'tools/list' => ['tools' => mcp_tool_descriptors()],
        'tools/call' => mcp_run_tool($body['params']),
        default      => null,
    };
    return ['jsonrpc' => '2.0', 'id' => $id, 'result' => $result];
}

permission_callback => '__return_true' is not the wall. l6 covered why — the wall is the formatter, not the gate.

Formatter. The wall lives in the WP_Query args, not in response shaping:

// includes/tools/list-posts.php
function list_posts_tool() {
    $query = new WP_Query([
        'post_type'      => 'post',
        'post_status'    => 'publish',
        'has_password'   => false,
        'posts_per_page' => 20,
    ]);
    return array_map(fn($p) => [
        'title'     => $p->post_title,
        'slug'      => $p->post_name,
        'permalink' => get_permalink($p),
    ], $query->posts);
}

No admin handle in the call chain. Run l6's audit question against it — if every permission check failed open today, what would leak? Drafts and password-protected posts are not in the query. They are not reachable from this function at all.

Discovery file. A template_redirect hook intercepts the path and serves a static JSON response:

// includes/well-known.php
add_action('template_redirect', function () {
    if ($_SERVER['REQUEST_URI'] !== '/.well-known/mcp.json') return;
    if (! get_option('mcp_catalog_public')) {
        status_header(404);
        exit;
    }
    header('Content-Type: application/json');
    echo json_encode([
        'mcp' => ['endpoint' => home_url('/wp-json/your-prefix/v1/mcp')],
    ]);
    exit;
});

The shape of that JSON is a 2026-04 snapshot — verify against modelcontextprotocol.io before shipping. The 404 when the catalog flag is off is load-bearing: an empty {} still announces the endpoint exists, same trap as l5's missing file.

Test the loop with two curl commands, no auth:

curl https://yoursite.com/.well-known/mcp.json
# → {"mcp":{"endpoint":"https://yoursite.com/wp-json/your-prefix/v1/mcp"}}

curl -X POST https://yoursite.com/wp-json/your-prefix/v1/mcp \
  -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 credentials, or you have not shipped.

Three options today, and why this generalizes

Install wp-mcp-discovery as a drop-in, write the three pieces yourself, or wait twelve to eighteen months for an SEO plugin to ship it. If you control the calendar, you do not have to wait. The pattern ports — Drupal entity types, Shopify products and collections, Sanity/Contentful/Strapi. Any CMS with a typed data model and an existing REST API wraps the same way. When you ship a CMS, an SEO plugin, or a framework now, the MCP discovery layer is part of the table-stakes API surface.

If you get stuck

  • template_redirect fires too late. WordPress has already routed to a 404 page. Use a higher-priority hook (parse_request, priority 1) or a rewrite rule in .htaccess/nginx.
  • The formatter reads with post_status: any and filters at response time. The audit question fails — one wrong conditional ships drafts. Move the wall into the WP_Query args.
  • The discovery file returns {} instead of a 404 when the catalog is off. The empty object still leaks the endpoint URL. Send 404 and exit so the disabled state is genuinely silent.

Cross-reference

l5 (m1) is the same loop on a static site — read it first if the three roles still feel abstract. l6 (m2) is the formatter deep dive that explains why the WP_Query args are the wall. l1 (m3) covers rate-limit calibration once OAuth lands and real AI-client traffic shows up.

Deliverable

Name the three roles for a WordPress site you own — endpoint route, formatter function, discovery file mechanism — and run both curl commands without auth. Both succeed, or you have not shipped.

Deployed in the wild

wp-mcp-discovery shipped to adventurebuildr.com on 2026-05-07. Twenty-eight tools registered automatically — Posts, Pages, MemberPress courses and lessons, plus eleven custom post types from AdventureBuildr (iasb_character, iasb_location, iasb_vehicle, iasb_weapon, iasb_lore, etc.). Under five minutes from plugin install to first successful tools/call. Verify it yourself: curl https://adventurebuildr.com/.well-known/mcp.json.

One compatibility note from that deploy: All in One SEO Pro v4.9.6.2 added llms.txt generation in early 2026, and AIOSEO's handler registers earlier than ours, so AIOSEO's llms.txt is what AI crawlers see when both plugins are active. There is no conflict — the MCP-specific surfaces (/.well-known/mcp.json and /wp-json/wp-mcp-discovery/v1/mcp) are unaffected because AIOSEO does not touch those namespaces. AIOSEO ships the index layer; this plugin ships the query layer; both run cleanly side by side.

Further reading

Try these prompts

  • Write a register_rest_route call that exposes /mcp under a custom namespace with a JSON-RPC 2.0 handler dispatching initialize, tools/list, and tools/call.
  • Write a list_posts WordPress formatter that uses WP_Query with post_status=publish and has_password=false, and returns title, slug, and permalink for each result.
  • Write the template_redirect hook that serves a /.well-known/mcp.json JSON object when a public catalog flag is on, and returns a 404 when the flag is off.

Back to contents

MCPBuild a public MCP server

3. Formatters are the security architecture, not the permission check 13 min

Lesson notes

Hook

A public MCP endpoint with permission_callback => '__return_true' looks like a security incident. The door is wide open. So what keeps the response from leaking member counts, access rules, and custom meta? Not the permission check — that returned true. The formatter. The line below the gate is the wall.

Concept

Prerequisites: you have shipped an HTTP endpoint that returns structured JSON, you have written a permission check (current_user_can, request.user.has_perm, a middleware decorator), and you have written something that turns a database row into an API response — a serializer, a resource, a formatter, a GraphQL resolver. This lesson is the deep dive on the formatter half of m1 l5's three-role discovery loop.

Locked doors vs empty drawers

A permission check is a locked door. It controls who walks into the room. A formatter is what sits in the drawer once they are inside. Lock the door and leave the admin keys in the drawer, and a kicked door is a key handout. Empty the drawer of admin keys, and a kicked door is nothing — the attacker is standing in a room that does not contain what they came for.

The gate decides who reaches the endpoint. The formatter decides what the endpoint is capable of returning at all. That makes it the stronger guarantee, not the weaker one.

The audit question

Walk every endpoint with this one prompt: if every permission check failed open today, what would leak? If the answer is "nothing, because the public method literally cannot assemble admin fields," the wall is in the right place. If the answer involves a formatter that conditionally hides fields based on a flag, the wall is conditional — which means it is not a wall.

Walk-through

The wrong shape is a single formatter parametrized by audience:

// MembershipFormatter.php (before)
public static function detail(MeprProduct $product, bool $is_public): array {
    $data = [
        'id'         => $product->ID,
        'title'      => $product->post_title,
        'price'      => $product->price,
        'signup_url' => $product->signup_url(),
    ];
    if (!$is_public) {
        $data['member_count'] = $product->member_count();
        $data['access_rules'] = $product->access_rules();
        $data['custom_meta']  = $product->custom_meta();
    }
    return $data;
}

Flip the flag, fail the auth check open, or invert the toggle in a refactor — every one of those leaks admin data through the same code path. The flag is the bug magnet.

The right shape is two methods on the same class:

// MembershipFormatter.php (after)
public static function detail(MeprProduct $product): array {
    return [
        'id'           => $product->ID,
        'title'        => $product->post_title,
        'price'        => $product->price,
        'signup_url'   => $product->signup_url(),
        'member_count' => $product->member_count(),
        'access_rules' => $product->access_rules(),
        'custom_meta'  => $product->custom_meta(),
    ];
}

public static function public_detail(MeprProduct $product): array {
    return [
        'id'         => $product->ID,
        'title'      => $product->post_title,
        'price'      => $product->price,
        'signup_url' => $product->signup_url(),
    ];
}

There is no flag to flip. public_detail() does not call member_count() — the field is not in the function's reach. The duplication is the wall.

The shape ports. Flask/DRF:

# serializers.py
class MembershipSerializer(serializers.ModelSerializer):
    class Meta:
        model = Membership
        fields = ['id', 'title', 'price', 'signup_url',
                  'member_count', 'access_rules', 'custom_meta']

class PublicMembershipSerializer(serializers.ModelSerializer):
    class Meta:
        model = Membership
        fields = ['id', 'title', 'price', 'signup_url']

Express/Node uses two functions: formatMembership(m) returns everything, formatPublicMembership(m) returns the catalog subset.

For GraphQL, the schema is the formatter. Define separate Membership and PublicMembership types. Field-level @auth directives on a single type are the $is_public flag in different syntax — same conditional wall, same audit failure.

If you get stuck

  • You parametrized because the duplication felt wrong. The duplication is the wall. A shared private helper both methods call is fine; a single public method with a flag is not.
  • You have one model class and don't want two formatters. The formatter belongs to the audience, not the model. One model, two audiences, two formatters.
  • GraphQL @auth directives feel cleaner. They are the conditional shape with prettier punctuation. Run the audit question against them — if the auth resolver fails open, the field still serializes. Same bug, different syntax.

Cross-reference

m1 l5 names the formatter as one of three roles in the static-site discovery loop; this lesson is the deep dive on that role. m3 l2 (two-axis MCP authorization) is the gate side of the same architecture — capability checks at the dispatcher, formatter walls at the response. Together they are how a public MCP server stays safe.

Deliverable

You have identified one endpoint in your codebase whose formatter takes an is_public or $scope flag, split it into two methods, and walked the audit question — if every permission check failed open today, what would leak? — until the answer is "nothing the public method can assemble."

Further reading

Try these prompts

  • Write a public_detail() formatter method on a MembershipFormatter class that returns id, title, price, and signup_url and explain why it cannot leak member_count, access_rules, or custom_meta even if the auth check fails open.
  • Refactor a single detail($product, $is_public) PHP formatter into two methods — detail() and public_detail() — and walk the audit question "if every permission check failed open today, what would leak" against both versions.
  • Translate the two-formatter split into a Flask/DRF stack — write MembershipSerializer and PublicMembershipSerializer as separate classes and explain why a single serializer with a fields kwarg is the same conditional bug in different syntax.

Back to contents

AuthAuth and rate limits for agent traffic

4. Rate-limit OAuth /register for AI clients, not human OAuth 13 min

Lesson notes

Hook

The server logs say "rate limit working as designed." The user's connector UI says "couldn't connect." Nothing in between names the cause. The rate limiter on your /register endpoint was tuned for one developer testing one app — not for thousands of users sharing one AI client's egress IP.

Concept

Prerequisites: you have shipped a public HTTP endpoint, you know what OAuth dynamic client registration (RFC 7591) is, you have read the agent-discovery-loop lesson in api-fundamentals, and you have written a naive per-IP rate limiter. This lesson teaches how to recalibrate one for a traffic shape you have not seen yet. /register here is the OAuth dynamic-client-registration endpoint, where a new client introduces itself before it has credentials. Not your authenticated API.

Why human OAuth and AI OAuth look the same on paper but break in opposite directions

An apartment building has one front door. Human OAuth is the resident model: you move in, get a key, and never queue at the door again. The lock can be slow, the lobby small — none of it matters. The resident passes through once and reuses the key for years.

AI OAuth is the visitor model. The AI client does not keep keys. Claude.ai re-registers an OAuth client on every connection attempt — no client_id persists across user sessions. Claude.ai also routes a whole region through a small set of egress IPs, so every visitor knocks on the same door, from the same address, every time they want in. The door is the lobby is the queue. A 5-per-hour-per-IP limit sized for one resident-per-year evaporates by lunch.

Walk-through

The tutorial default you copied has two flaws: a 5/hour threshold (a residential lobby, not a public turnstile) and a 429 with no Retry-After and no actionable message. The AI client retry-storms; the user sees a generic failure. The fix raises the threshold and makes the 429 carry useful information:

// register-controller.php (after)
private function check_registration_rate_limit(): ?WP_REST_Response
{
    $ip       = $this->get_client_ip();
    $key      = 'mp_mcp_register_' . md5($ip);
    $attempts = (int) get_transient($key);

    if ($attempts >= 60) {
        return new WP_REST_Response(
            [
                'error'             => 'too_many_requests',
                'error_description' => 'Too many client registrations from this IP. Please try again in an hour.',
            ],
            429,
            ['Retry-After' => '3600']
        );
    }

    set_transient($key, $attempts + 1, HOUR_IN_SECONDS);
    return null;
}

The Flask shape is the same three moves:

# routes/register.py
if attempts >= 60:
    return jsonify(
        error="too_many_requests",
        error_description="Too many client registrations from this IP. Please try again in an hour.",
    ), 429, {"Retry-After": "3600"}

60/hour is roughly one per minute — still abuse-resistant (an attacker can't fingerprint your tenant boundary or exhaust resources at that rate), but it absorbs dozens of legitimate AI users behind one egress IP. As of 2026-04-30 this is the working number for Claude.ai connector traffic. Treat it as a current example, not a ceiling. Watch your 429 rate after deploy and raise the floor if real users still hit it.

Reproduce the failure locally before users do:

# scripts/probe-register.sh
for i in {1..70}; do
  curl -s -o /dev/null -w "%{http_code}\n" \
    -X POST https://yoursite.com/oauth/register \
    -H "Content-Type: application/json" \
    -d '{"client_name":"probe","redirect_uris":["http://localhost"]}'
done
# → 200 for the first batch, then 429 once you cross the threshold

If the 429 arrives without a Retry-After or with a generic "Try again later" body, the threshold is fine but the diagnostic is invisible — the silent-failure shape survived the bump.

Field signature: when a Claude Desktop user reports "couldn't connect," ask for the connector URL and look for step=start_error. That fragment means /register failed before login could start. The probe above reproduces the same shape from your laptop.

If you get stuck

  • The limit lives in a layer you do not control (CDN, WAF, hosting plan). Your application code looks correct; the 429 is emitted upstream with no Retry-After. Find the layer, move the limit into your app, or have the upstream provider widen it for /register.
  • You raised the threshold but the 429 still has no Retry-After. AI clients without that header retry on their own schedule, which compounds the load. Add the header. One line.
  • Your error message is generic, so the user can't self-diagnose. "Try again later" tells them nothing. Replace with a specific window — "Try again in an hour" — and AI client surfaces pass it through to the user where it does some good.

Cross-reference

The agent-discovery-loop lesson in api-fundamentals (l9) is the public-MCP plus .well-known/mcp.json pattern this auth surface lives behind. Every endpoint in that discovery file shares this calibration problem: any per-IP limit on a path AI clients touch during connection setup needs to be sized for shared egress, not single-developer traffic.

Deliverable

Your /register endpoint accepts at least 60 registrations per IP per hour. On overflow it returns 429, a Retry-After: 3600 header, and an error_description naming the wait time in concrete units. You can describe — without checking notes — the diagnostic fragment in a Claude Desktop connector URL that tells you a /register rate limit fired before login could start.

Further reading

  • "Closing the Agent Discovery Loop" — the conceptual framing for why an AI agent's traffic shape differs from a human's, and what the public surface needs to look like.

Try these prompts

  • Rewrite a check_registration_rate_limit() function so the threshold is 60/hour and the 429 response includes a Retry-After header and an actionable error_description.
  • Describe the diagnostic signature in Claude Desktop that tells you a /register rate limit fired before login could even start, and the manual POST that reproduces it.
  • List three endpoints in an MCP or OAuth flow that AI clients hit during connection setup and explain why per-IP rate limits there need calibration for shared egress, not human single-user traffic.

Back to contents

5. Two-axis MCP authorization — scope and capability 14 min

Lesson notes

Hook

An Author-role user with a full-scope MCP token created a WordPress admin through Claude Desktop. The dashboard would have stopped them at the click. The MCP server didn't, because it never asked WordPress whether this user was allowed.

Concept

Prerequisites: you have shipped a tool dispatcher behind an OAuth boundary, you know what a token scope is, and you have written a handler that calls some kind of current_user_can or request.user.has_perm. This lesson assumes you already picture the MCP server as an HTTP service with token-scoped clients (l1 is the other AI-traffic gotcha on this same surface).

One word to disambiguate first: capability here means the platform's permission — create_users, manage_options, the cap WordPress checks before letting a click through the dashboard. Not OAuth scope, not "what tools this MCP server offers." Same word, three uses. We mean the platform one.

Two axes, not one

A token scope tells you what kind of action the client can do at all: read or write. A platform capability tells you whether this user may do this specific action. They are independent.

Picture a hotel keycard and a room number. The keycard says you have a room in this hotel. The room number says which door it opens. A scope-only check is the lobby waving any keyholder onto every floor. The dashboard already knows the room numbers — every current_user_can call is a door — but the MCP server walked the guest past them.

When AI agents are the clients, this gap is reachable from a chat box. A user who would never click the WP "Add User" button can ask Claude to do it for them.

Walk-through

A tool descriptor that names both axes:

// includes/tool-registry.php
'memberpress_create_member' => [
    'requires_full_access' => true,           // axis 1: scope
    'requires_cap'         => 'create_users', // axis 2: WP cap
    'handler'              => 'create_member_handler',
],

The dispatcher checks them in order — scope first because it is cheap and broad, capability second because it is specific and needs user context:

// includes/dispatcher.php
if ($tool['requires_full_access'] && ! $token_has_full_scope) {
    return error('SCOPE_INSUFFICIENT', 403);
}

if (! empty($tool['requires_cap']) && ! current_user_can($tool['requires_cap'])) {
    return error('CAPABILITY_INSUFFICIENT', 403);
}

Two distinct error codes is load-bearing. SCOPE_INSUFFICIENT means "your token cannot do writes at all, request a fuller token." CAPABILITY_INSUFFICIENT means "your token is fine, your user isn't, talk to whoever owns the role." One 403 for both forces guesswork at the chat log.

The capability check only works if the request knows who the user is. In the auth callback that resolves the token, set the WP user before any handler runs:

// includes/auth-callback.php
$token = $this->resolve_token($bearer);
wp_set_current_user($token->user_id);

Skip this line and current_user_can returns false everywhere — including for the legitimate token owner — so every write tool 403s for the wrong reason.

The translation move

The shape ports to a Flask + DRF-style stack. Two permission classes, two distinct 403 responses, one per class:

# routes/tools.py
class CreateMemberView(APIView):
    permission_classes = [TokenHasFullScope, HasPlatformPerm("members.add_member")]

    def post(self, request):
        # request.user is bound by TokenAuthentication before this runs
        ...

Do not invent a new permission system for the MCP layer. If the platform's UI requires create_users to add a user, the MCP tool requires create_users to add a user. Match the existing model. A parallel one means two places to keep in sync, and they will drift.

If you get stuck

  • The capability check passes for users it should block. The auth callback never set the user identity. Add wp_set_current_user($token->user_id) (or request.user = token.user in a Django auth backend) before any handler runs.
  • A read-only token can call write tools as long as the user has the cap. You wired requires_cap and skipped scope. Both axes, every dispatch.
  • You are on a hosted platform with no auth-middleware hook. Vercel functions in front of an external API, Cloudflare Workers, Zapier-style runtimes — there is no place to set a platform user before the handler runs. Do the capability check inside the tool body using the platform's own API (call it with the user's credentials and let it refuse), or do not expose tools whose capability axis you cannot enforce.

Cross-reference

l1 in this module is the same auth surface from the other side: rate-limiting /register so AI traffic does not lock legitimate users out. l1 closes the door on credential-less abuse; this lesson closes the door on scope-only abuse once the user has credentials.

Deliverable

A tool descriptor with both requires_full_access and requires_cap fields, a dispatcher that checks them in that order and returns SCOPE_INSUFFICIENT and CAPABILITY_INSUFFICIENT as distinct codes, and a probe — Author-role token, full scope, calls memberpress_create_member — that returns 403 CAPABILITY_INSUFFICIENT. The same probe with an Admin token returns 200.

Further reading

Try these prompts

  • Write a tool descriptor for memberpress_create_member that declares requires_full_access true and requires_cap create_users, and write the dispatch function that checks scope first and capability second with distinct error codes.
  • Write the auth callback that calls wp_set_current_user from the token's user_id, and explain why current_user_can returns false everywhere downstream if you skip it.
  • Translate the two-axis check into a Flask handler that uses a permission_classes-style decorator — show the scope check, the capability check, and the distinct 403 responses for each axis.

Back to contents

6. Allowlist OAuth redirect URIs by host, not full path 12 min

Lesson notes

Hook

Claude.ai shipped a new connector flow on a Tuesday. The redirect path moved from /api/mcp/auth_callback to /api/mcp/oauth-redirect. Every existing connection broke. Server logs read invalid_redirect_uri. The user UI said "couldn't connect." Nothing in your codebase had changed.

Concept

Prerequisites: you finished l1 in this module, you have shipped a public OAuth /register endpoint, and you know what dynamic client registration (RFC 7591) is. This lesson teaches what to allowlist when the other end of the contract belongs to a team that ships without telling you.

Why pinning the path fails

A redirect URI is two things in one string. The host (claude.ai) is a stable name — like an airport code. You can verify it against a published list of partners. The path (/api/mcp/oauth-redirect) is internal routing — the gate assignment, which the airline rewrites when a new terminal opens. Pinning both is signing a contract you only own half of.

When you pin the exact path, you are guessing at a value the client team changes without notice. The first guess works. The next client deploy invalidates it. You learn from a support ticket.

Walk-through

The brittle shape from the original incident:

// register-controller.php (before)
$allowed_callback = '/api/mcp/auth_callback';
return $path === $allowed_callback;

The short-term fix extends the path list when a new path appears. It buys one release cycle. It does not fix the contract:

$allowed_callback = ['/api/mcp/auth_callback', '/api/mcp/oauth-redirect'];
return in_array($path, $allowed_callback, true);

The structural fix pins the host strictly and prefix-matches the path:

// register-controller.php (after)
if (in_array($host, ['claude.ai', 'claude.com'], true)) {
    return str_starts_with($path, '/api/mcp/');
}
return false;

The Flask shape is the same three moves:

# routes/register.py
TRUSTED_HOSTS = {"claude.ai", "claude.com"}
if host in TRUSTED_HOSTS and path.startswith("/api/mcp/"):
    return True
return False

Then expose a filter so the next AI client an operator encounters is a one-line config change, not a release:

$extra_hosts = apply_filters('mp_mcp_oauth_allowed_redirect_hosts', [], $uri);
if (!empty($extra_hosts) && in_array($host, $extra_hosts, true)) {
    return str_starts_with($path, '/api/mcp/');
}

The Python equivalent is a registry the operator extends at boot — a JSON file, an env var, an entry-point hook. Same shape, different idiom. The host strings (claude.ai, claude.com) and the path prefix (/api/mcp/) are the 2026-04-30 snapshot. The principle survives churn; the strings are examples.

Reproduce the rejection from your laptop before a user does:

# scripts/probe-redirect.sh
curl -s -X POST https://yoursite.com/oauth/register \
  -H "Content-Type: application/json" \
  -d '{"client_name":"probe","redirect_uris":["https://claude.ai/api/mcp/some-future-path"]}' \
  | jq .
# → with the structural fix, returns a client_id
# → without it, returns invalid_redirect_uri

Field signature: a Claude Desktop user sees step=start_error in the connector URL — same fragment l1 covers, different cause. If your rate limit is healthy, the rejection is the redirect URI. The probe above reproduces it.

If you get stuck

  • You pinned the host but skipped the prefix check. Any path on claude.ai now passes, including /admin or /billing. Add str_starts_with($path, '/api/mcp/') and write the negative test that should fail.
  • You allowed a wildcard host. *.claude.ai matches evil-claude.ai.attacker.com under most naive parsers. Strict equality on the host string, full stop.
  • You shipped the filter but never documented it. The next operator hits the same wall and patches with a code release anyway. One paragraph in your README pointing at mp_mcp_oauth_allowed_redirect_hosts saves the cycle.

Cross-reference

Sibling lesson l1 covers the rate-limit shape on the same /register endpoint. Both failures surface as step=start_error — a manual POST to /register with the client's expected redirect URI is the disambiguator between "rate limit fired" and "redirect URI rejected."

Deliverable

Your /register endpoint accepts a registration whose redirect URI is https://claude.ai/api/mcp/<any-future-path> provided the host is in your strict list and the path is under your prefix. You can describe — without checking notes — why allowlisting on path alone is brittle for clients you do not control, and the one-line filter you would expose to an operator who finds a new client tomorrow.

Further reading

Try these prompts

  • Rewrite an OAuth redirect-URI allowlist check so it strict-matches the host against a known partner list and prefix-matches the path, then add a filter hook that lets an operator extend the host list without a code release.
  • Describe the diagnostic difference between a step=start_error caused by a /register rate limit and one caused by an invalid_redirect_uri rejection, and write the manual POST you would run from your laptop to disambiguate them.
  • Name three failure modes a structural host-plus-prefix allowlist still has — over-permissive path acceptance, wildcard host accepting attacker subdomains, and an undocumented filter hook — and write a one-line guardrail for each.

Back to contents

Beyond MCPBeyond MCP — OpenAPI, Custom GPTs, citation

7. GPT Actions vs MCP — different discovery topology, same goal 12 min

Lesson notes

Hook

You shipped MCP. Cursor finds you, Claude Desktop finds you, Claude Code finds you. ChatGPT users still can't reach your tool. The fix is not a second MCP server — it's a different topology.

Concept

Prerequisites: a public HTTP endpoint, a working sense of what .well-known/mcp.json does, the ability to read an OpenAPI 3.x fragment, and m3 l1 (Rate-limit OAuth /register for AI clients) behind you. This lesson covers the mental shift between two ways an AI agent learns your tool exists, and the single artifact that serves both.

Two doors, opposite directions

MCP is the front door with a sign. The agent walks up, reads .well-known/mcp.json, finds the endpoint, queries the schema, and starts calling operations — no human in the loop. Server-mediated discovery.

GPT Actions is the front door with a guest list. ChatGPT does not crawl for tools. A human opens the GPT builder, pastes your OpenAPI URL into the Actions panel, clicks deploy, and only then does the agent learn your address. User-mediated attachment.

Same goal. Opposite paths to the door. Ship only one and you reach half the agent population.

One artifact, two consumers

A single OpenAPI 3.x spec at a public URL covers both. The MCP server wraps it for the agent-mediated path. The Custom GPT imports it for the user-mediated path. Build the spec once; both ecosystems read it. That is the entire architectural shift. Everything below is mechanics.

Walk-through

You already have the OpenAPI URL — the same spec your MCP server exposes (in this project it lives at workers/rag.js's /openapi.json route). The Custom GPT side has four moves.

  1. Open the GPT builder at https://chatgpt.com/gpts/editor → Configure → Actions → Import from URL.
  2. Paste the OpenAPI URL. ChatGPT validates the schema and lists every operation it found.
  3. Pick the auth mode:
    • None — read-only public reads.
    • API Key in headers — do not use this for user-scoped data. The GPT creator's key gets reused across every conversation, so user A's data leaks into user B's session.
    • OAuth 2.0 — the right answer for anything user-scoped.
  4. Before testing OAuth, allowlist https://chat.openai.com in your OAuth provider. Custom GPT OAuth flows redirect to https://chat.openai.com/aip/g-<gpt-id>/oauth/callback. If your allowlist is locked to your own domain, the first user's auth attempt fails with redirect_uri_mismatch — the failure is invisible from your side; the user sees a generic OpenAI error page. (Redirect URI pattern current as of 2026-05-07; verify against platform.openai.com/docs/actions before shipping.)

Set the GPT to Public if directory discovery matters — ChatGPT's GPT store is search-indexed — or Private for invite-only. Same citation hygiene as MCP: if your endpoint returns url fields in its JSON, the GPT cites the source. Without them, ChatGPT summarizes without attribution.

If you get stuck

  • redirect_uri_mismatch on the first OAuth round-trip. You forgot to allowlist https://chat.openai.com. Add it (or the specific callback path) to your OAuth provider's redirect list and retry.
  • The Custom GPT works for the creator but fails for every other user. You picked API Key in headers. The creator's key is the only key the GPT carries. Switch the auth mode to OAuth and re-import.
  • ChatGPT can't see your tool at import time. The OpenAPI URL is behind auth, returns the wrong content-type, or 404s for unauthenticated requests. Curl it from a private window; you should get application/json (or application/yaml) at a public URL with no auth required.

Cross-reference

m3 l1 (Rate-limit OAuth /register for AI clients) applies to both surfaces — every endpoint either ecosystem touches needs the same calibration, because ChatGPT user traffic shares the same egress-IP shape as Claude.ai.

Deliverable

You have a Custom GPT in the ChatGPT builder that imports your existing OpenAPI URL, with OAuth configured (or none if read-only), https://chat.openai.com on the OAuth allowlist, and at least one operation tested end-to-end with a real user account. You can say without checking notes: MCP is server-mediated discovery, GPT Actions is user-mediated attachment, and the OpenAPI spec is the one artifact that serves both.

Further reading

Try these prompts

  • Explain in three sentences why a single OpenAPI 3.x spec at a public URL covers both an MCP server and a ChatGPT Custom GPT Action, and which two consumers read it differently.
  • Walk through the exact failure mode when a Custom GPT's OAuth flow first redirects a user, given the GPT redirect URI pattern, and name the one allowlist change that fixes it.
  • Compare GPT Actions' three auth modes (none, API key in headers, OAuth 2.0) for a tool that returns user-scoped data, and explain why API key in headers is brittle in that case.

Back to contents