Operating at Scale

Performance Optimization Under Agent Traffic

⏱ 15 min

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.

What you'll learn in this lesson
Caching execute_callback results with the object cache
wp_cache_get() / wp_cache_set(), keyed correctly on input.
Spotting the N+1 pattern in list abilities
Where a single ability call quietly issues one query per item.
Cache invalidation that actually stays correct
Tying cache clears to the same actions that change the underlying data.
When not to cache at all
Abilities marked non-idempotent or destructive have different rules.
Prerequisites

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.

Caching decision, by ability annotation
Ability’s meta.annotationsSafe to cache?Notes
readonly: trueYesBest candidate; cache freely with a sensible TTL.
idempotent: true, not readonlySometimesRepeated 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: trueNoNever 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.

Don't cache permission checks

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

← Observability and Monitoring for MCP Servers Migrating Legacy Plugins to the Abilities API →