# Formatters are the security architecture, not the permission check

Web page: https://academy.memberpress.ai/courses/agent-discoverable-surfaces/l6/

Lesson 3 of 7 in [Agent-Discoverable Surfaces](https://academy.memberpress.ai/courses/agent-discoverable-surfaces/), 13 min.

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

**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:

```php
// 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:

```php
// 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:

```python
# 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**

- ["Closing the Agent Discovery Loop"](https://sethshoultes.com/blog/closing-the-agent-discovery-loop.html) — the conceptual framing for why a public MCP surface needs both a gate and a ceiling, and what each one is doing.

## 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.

Next: [Rate-limit OAuth /register for AI clients, not human OAuth](https://academy.memberpress.ai/courses/agent-discoverable-surfaces/l1/)
