Advanced WordPress MCP Architecture built a real McpObservabilityHandlerInterface
implementation to answer “what’s happening on this server.” A multi-agent system built out of
several servers and multi-step coordinator chains needs a sharper version of the same
question: which agent, running which chain, on which step, and how long is each step taking.
Be direct about what this lesson is and isn’t: there’s no built-in WordPress orchestration
dashboard, no dedicated multi-agent monitoring product. This is the same custom pattern from
earlier courses, extended with a bit more structure, because that’s genuinely the honest state
of the art here.
The custom McpObservabilityHandlerInterface implementation from Advanced WordPress MCP
Architecture’s observability lesson, and the coordinator chain from Lesson 5 of this course.
This lesson extends that handler rather than building a new one from scratch.
Step 1: What a per-server handler already gives you, and where it stops
The observability handler from earlier courses answers questions scoped to one server:
call volume, error rate by FailureReason, latency per event. Register that same handler
class on both the order-ops-support-server and order-ops-fulfillment-server from
Lesson 1, and you already get independent metrics per agent role, which server is getting
hammered, which one is erroring more. What it doesn’t give you for free is chain-level
visibility: when order-ops/coordinate-return calls four other abilities internally via
wp_get_ability()->execute(), none of that happens through the adapter’s own request
lifecycle, since those calls never go through MCP transport at all, they’re plain PHP
function calls. The adapter’s record_event() never fires for them unless you call it
yourself.
Step 2: Recording chain-internal events explicitly
The fix is direct: call your own handler’s record_event() method manually from inside the
coordinator, the same method the adapter calls automatically for its own request lifecycle,
tagged with a chain ID that ties every step together.
// File: order-ops/class-coordinate-return.php (instrumented)
function order_ops_coordinate_return( $input ) {
$order_id = $input['order_id'];
$chain_id = wp_generate_password( 12, false ); // one ID shared by every step in this run
$handler = new My_Plugin_Observability_Handler();
$run_step = function ( string $step_name, string $ability_name, array $args ) use ( $chain_id, $handler ) {
$start = microtime( true );
$result = wp_get_ability( $ability_name )->execute( $args );
$ms = ( microtime( true ) - $start ) * 1000;
$handler->record_event(
is_wp_error( $result ) ? 'chain_step_failure' : 'chain_step_success',
array(
'chain' => 'coordinate-return',
'chain_id' => $chain_id,
'step' => $step_name,
'ability' => $ability_name,
),
$ms
);
return $result;
};
$eligibility = $run_step( 'validate', 'order-ops/validate-return-eligibility', array( 'order_id' => $order_id ) );
if ( is_wp_error( $eligibility ) || empty( $eligibility['eligible'] ) ) {
return new WP_Error( 'not_eligible', 'Order is not eligible for return.' );
}
$refund = $run_step( 'refund', 'order-ops/refund-order', array( 'order_id' => $order_id, 'amount' => $eligibility['refund_amount'] ) );
if ( is_wp_error( $refund ) ) {
return $refund;
}
$restock = $run_step( 'restock', 'order-ops/restock-returned-item', array( 'order_id' => $order_id ) );
if ( is_wp_error( $restock ) ) {
return $restock;
}
$run_step( 'notify', 'order-ops/notify-customer-refunded', array( 'order_id' => $order_id, 'amount' => $eligibility['refund_amount'] ) );
return array( 'order_id' => $order_id, 'chain_id' => $chain_id );
}
Every step now records its own chain_step_success or chain_step_failure event, tagged
with the same chain_id, the specific step name, and its own duration. Nothing about the
adapter’s own request-level metrics changed, this is purely additional, manual instrumentation
for the part of the system the adapter genuinely can’t see: work happening inside one
execute_callback, not across separate MCP requests.
Step 3: Extending the handler to bucket by chain step
The handler class itself needs almost no change, just recognize the new tags:
// File: includes/class-my-plugin-observability-handler.php (extended)
public function record_event( string $event, array $tags = array(), ?float $duration_ms = null ): void {
$bucket = 'my_plugin_mcp_metrics_' . gmdate( 'YmdH' );
$data = get_transient( $bucket ) ?: array( 'events' => array(), 'by_chain_step' => array() );
$data['events'][ $event ] = ( $data['events'][ $event ] ?? 0 ) + 1;
if ( isset( $tags['chain'], $tags['step'] ) ) {
$key = $tags['chain'] . ':' . $tags['step'];
$data['by_chain_step'][ $key ]['count'] = ( $data['by_chain_step'][ $key ]['count'] ?? 0 ) + 1;
if ( null !== $duration_ms ) {
$data['by_chain_step'][ $key ]['total_ms'] = ( $data['by_chain_step'][ $key ]['total_ms'] ?? 0 ) + $duration_ms;
}
}
set_transient( $bucket, $data, HOUR_IN_SECONDS * 2 );
}
by_chain_step['coordinate-return:restock'] now accumulates a count and total duration
specifically for the restock step, across every run of the chain in that hour, exactly what
you need to answer “which step in this chain is actually slow” instead of only “how slow was
the whole coordinator call.”
| Question | Per-server metric alone | With chain_id and step tags |
|---|---|---|
| Which agent server is busiest? | Yes | Yes |
| Which specific step in a chain is slow? | No, only total call duration | Yes, per-step total_ms |
| Did every step in one specific run complete? | No | Yes, filter events by chain_id |
| Which failure reason dominates a given step? | Only per-ability, not per-step-in-chain | Yes, tags carry both |
Test it
# File: terminal
wp-env run cli wp eval '
wp_get_ability( "order-ops/coordinate-return" )->execute( array( "order_id" => 4821 ) );
var_dump( get_transient( "my_plugin_mcp_metrics_" . gmdate("YmdH") )["by_chain_step"] );
'
You should see four keys, coordinate-return:validate, coordinate-return:refund,
coordinate-return:restock, and coordinate-return:notify, each with a count and
total_ms. Run the chain a handful of times and confirm the counts accumulate correctly and
that one deliberately slowed-down step (add a usleep() to test) shows a clearly higher
average in its bucket than the others.
It’s easy to register a real observability handler on your servers, see it working for
ordinary tool calls, and assume a coordinator’s internal chain is automatically covered too.
It isn’t, wp_get_ability()->execute() calls never pass through the adapter’s request
lifecycle, so nothing calls record_event() for them unless your own coordinator code does,
as shown in Step 2. This is genuinely custom-pattern territory: there is no built-in
orchestration-level monitoring in WordPress or the MCP Adapter to fall back on.
Recap
Per-server observability from earlier courses still matters and still works unchanged, but a
coordinator chain’s internal steps happen outside the adapter’s own request lifecycle, so
they need explicit, manual record_event() calls tagged with a shared chain ID and a step
name. That’s a deliberate extension of the same McpObservabilityHandlerInterface handler
you already built, not a new monitoring system, and it’s the only way to see which specific
step in a multi-step chain is actually the slow or failure-prone one.
Resources & further reading
- MCP Adapter repository, GitHub
- Transients API, developer.wordpress.org
- Abilities API reference, developer.wordpress.org