Permission checks decide who can call an ability. This lesson is about a different, equally important question: what that ability actually hands back, even to someone correctly permitted to call it. An AI agent that can legitimately look up a customer order doesn’t necessarily need that customer’s billing email, phone number, and home address in the response. The best defense against leaking sensitive data to an AI agent is never producing it in the first place.
Understanding of input_schema and output_schema from Course 1, and Lesson 5’s permission-gated tools.
Step 1: Design output_schema around what the workflow needs, not what’s available
It’s common to build an ability by wrapping an existing internal function and returning whatever that function already returns. That’s convenient and frequently wrong for anything customer- or user-facing. Start from the actual agent workflow and work backward: what does “check whether an order shipped” actually require the model to see?
// File: my-plugin.php
'output_schema' => array(
'type' => 'object',
'properties' => array(
'order_id' => array( 'type' => 'integer' ),
'status' => array( 'type' => 'string' ),
'shipped_date' => array( 'type' => 'string' ),
),
),
No billing address, no email, no phone number, no payment details. If a future workflow genuinely needs one of those fields, add it deliberately at that point, with its own review, rather than exposing the whole customer record up front because it happened to be available in the underlying data structure.
Declaring a narrow output_schema communicates what the ability is supposed to return.
It does not stop execute_callback from actually returning more, unless the MCP layer
you’re using validates outbound responses against the schema strictly. Treat the schema
as documentation for reviewers and models, and treat the code in Step 2 as the actual
control.
Step 2: Redact explicitly inside execute_callback
The enforcement point is the return statement itself. Build the response as an
explicit, deliberately narrow array, never (array) $order or similar shortcuts that
pass through whatever fields happen to exist on the underlying object:
// File: my-plugin.php
'execute_callback' => function ( $input ) {
$order = wc_get_order( $input['order_id'] );
if ( ! $order ) {
return new WP_Error( 'not_found', 'No order with that ID exists.' );
}
// Deliberately narrow. Never return $order->get_data() directly.
return array(
'order_id' => $order->get_id(),
'status' => $order->get_status(),
'shipped_date' => $order->get_date_completed() ? $order->get_date_completed()->date( 'Y-m-d' ) : null,
);
},
Step 3: A concrete before-and-after
Consider an ability meant to let an agent answer “has this customer’s order shipped.”
// Wrong: exposes the entire order object
'execute_callback' => function ( $input ) {
$order = wc_get_order( $input['order_id'] );
return $order->get_data(); // includes billing email, phone, full address, payment method
},
// Right: exposes only what the stated purpose requires
'execute_callback' => function ( $input ) {
$order = wc_get_order( $input['order_id'] );
return array(
'order_id' => $order->get_id(),
'status' => $order->get_status(),
);
},
The second version is strictly less useful for any purpose beyond its stated one, and that’s the point. An ability that can only answer the question it was built for cannot be prompted, tricked, or accidentally used to exfiltrate data it never had access to.
Step 4: Why “the model probably will not misuse it” is not a control
Even a well-behaved, non-malicious AI client can still expose data it received: in a chat transcript a user later shares, in a log your MCP client or orchestration layer writes, in a summary the model produces for an unrelated downstream step, or simply by being asked directly by whoever has access to that conversation. None of this requires the model or the agent framework to do anything wrong. If an ability hands back a customer’s phone number, the honest assumption has to be that the phone number is now somewhere outside your database’s access controls, permanently.
Once data leaves execute_callback in a response, you no longer control where it goes.
Decide what to expose based on that assumption, not on how careful you expect the
receiving AI client to be.
Test it: audit an existing ability’s actual output
For any ability already exposed with meta.mcp.public set to true, call it directly and
inspect exactly what comes back:
wp-env run cli wp eval '
$ability = wp_get_ability( "my-plugin/get-order-status" );
$result = $ability->execute( array( "order_id" => 123 ) );
print_r( $result );
'
Compare the printed keys against the ability’s stated purpose. Anything present that isn’t required for that purpose should be removed.
A narrow top-level output_schema can still leak PII through a nested array, for
example a line_items array where each item includes a customer note field that was
never reviewed. Check nested structures with the same scrutiny as top-level fields.
Recap
The safest data an agent can leak is data it never received. Design output_schema
around the actual stated purpose of each ability, build execute_callback responses as
explicit, narrow arrays rather than passing through full objects, and treat any exposed
field as effectively permanent and public to the conversation it ends up in. The next
lesson extends this same caution to a related but different risk: untrusted, user
submitted content being treated as instructions rather than data.
Resources & further reading
- Abilities API reference, developer.wordpress.org
- WooCommerce order data reference, developer.woocommerce.com
- MCP Adapter repository, GitHub