MemberPress AI Academy memberpress.ai

MCP · Build a public MCP server

Formatters are the security architecture, not the permission check

Video13 min

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.