Agent traffic has a shape human traffic mostly doesn’t: an agent working through a
multi-step task will often call the same “list” or “get” ability several times in a
few seconds, re-checking state after every action it takes. A human clicking through
wp-admin doesn’t refetch the full post list eight times in ten seconds; an agent
completing an eight-step workflow plausibly does exactly that. If your
execute_callback runs an uncached query every time, you’ve just built a very
effective way to turn one agent session into a self-inflicted load spike.
Comfortable with WP_Query, the WordPress object cache functions, and the
meta.annotations block from Course 1 (readonly, idempotent), which is what tells
you whether an ability is even safe to cache.
Step 1: Cache the expensive, safe-to-cache callbacks
Only cache abilities annotated readonly (or at least idempotent); caching a
destructive or state-changing ability’s result is a correctness bug waiting to
happen, not a performance win. For a genuinely read-only ability, wrap the query in the
object cache, keyed on a hash of its actual input:
// File: my-plugin.php
function my_plugin_list_recent_orders( array $input ) {
$cache_key = 'my_plugin_orders_' . md5( wp_json_encode( $input ) );
$cached = wp_cache_get( $cache_key, 'my-plugin-mcp' );
if ( false !== $cached ) {
return $cached;
}
$orders = wc_get_orders( array(
'limit' => $input['limit'] ?? 20,
'status' => $input['status'] ?? 'any',
'orderby' => 'date',
'order' => 'DESC',
) );
$result = array_map( 'my_plugin_format_order_for_ability', $orders );
wp_cache_set( $cache_key, $result, 'my-plugin-mcp', 60 ); // 60-second cache
return $result;
}
A 60-second cache doesn’t mean an agent sees data that’s a minute wrong in any way that
matters, it means five calls to the same ability inside that minute cost one query
instead of five. On a persistent object cache (Redis or Memcached), this key is shared
across requests and even across PHP-FPM workers; without one, wp_cache_* still helps
within a single request but resets between requests, which is still worth having.
Step 2: Find the N+1 hiding inside a “list” ability
The most common performance bug in ability libraries isn’t a slow query, it’s a query per item hiding inside a loop that looks harmless:
// The N+1 version: one query per order, invisible until traffic scales.
foreach ( $orders as $order ) {
$customer = get_user_by( 'id', $order->get_customer_id() ); // separate query, every iteration
$result[] = array( 'id' => $order->get_id(), 'customer' => $customer->display_name );
}
// File: my-plugin.php
// The fixed version: one batch query for every customer needed.
$customer_ids = array_unique( array_map( fn( $o ) => $o->get_customer_id(), $orders ) );
$customers = array();
foreach ( get_users( array( 'include' => $customer_ids ) ) as $user ) {
$customers[ $user->ID ] = $user->display_name;
}
foreach ( $orders as $order ) {
$result[] = array(
'id' => $order->get_id(),
'customer' => $customers[ $order->get_customer_id() ] ?? '',
);
}
This isn’t MCP-specific WordPress advice, it’s ordinary batch-loading discipline. What makes it MCP-specific is that agents make this pattern visible far faster than human traffic does: a human paginating a list triggers this once per page load; an agent re-running the same “list open tickets” ability five times while working through a task triggers it five times in the same minute.
Step 3: Invalidate on the actions that actually change the data
A cache that’s never invalidated correctly is worse than no cache, because it’s wrong silently. Tie your cache clear to the same hooks that fire when the underlying data changes, not to a fixed TTL you hope is short enough:
// File: my-plugin.php
add_action( 'woocommerce_order_status_changed', function () {
wp_cache_flush_group( 'my-plugin-mcp' );
} );
wp_cache_flush_group() requires a persistent object cache backend that supports group
flushing; if you’re not running one, fall back to a short TTL (as in Step 1) combined
with flushing specific known keys where practical, rather than trying to flush an
entire group on a backend that doesn’t support it.
| Ability’s meta.annotations | Safe to cache? | Notes |
|---|---|---|
readonly: true | Yes | Best candidate; cache freely with a sensible TTL. |
idempotent: true, not readonly | Sometimes | Repeated calls with the same input are safe, but the call itself may still write; cache only the read portion if you can separate it. |
destructive: true | No | Never cache; every call must actually execute. |
Step 4: Watch what the agent is actually re-fetching
If you have an observability handler in place (previous lesson), the by_ability
counts you’re already collecting tell you exactly which abilities are getting called
repeatedly in short windows, which is your real prioritization signal. Optimize the
ability an agent calls fifty times in a session before the one it calls once.
Test it
Add logging or a simple counter to your uncached query, call the ability three times in a row through an MCP client within your cache TTL, and confirm the underlying query only actually ran once:
wp-env run cli wp eval 'var_dump( wp_cache_get( "my_plugin_orders_" . md5( wp_json_encode( array( "limit" => 20, "status" => "any" ) ) ), "my-plugin-mcp" ) );'
A non-false result confirms the cache is populated and being hit.
Cache the data an ability returns, never the result of its permission_callback.
Permission decisions must be evaluated fresh on every call, since the calling user’s
capabilities, session, or role can change between two calls made seconds apart.
Recap
Cache execute_callback results for readonly/idempotent abilities using the object
cache, keyed on the actual input, invalidated on the real data-changing action rather
than a hopeful TTL alone. Hunt for N+1 patterns in every “list” ability specifically,
since agent traffic surfaces them faster than human traffic does. Never cache
permission checks, and never cache anything from a destructive ability.
Resources & further reading
- Object Cache API, developer.wordpress.org
- Transients API, developer.wordpress.org
- MCP Adapter repository, GitHub