The MCP Adapter ships with NullMcpObservabilityHandler as its default, which is
honest about what it does: nothing. Every server you register without specifying an
observability handler is running with zero visibility into call volume, latency, or
error rate, by design, so the adapter never adds overhead for sites that don’t want it.
For anything running in production, that’s not acceptable, and the fix isn’t a
third-party plugin, it’s implementing the one real extension point the adapter gives
you: McpObservabilityHandlerInterface.
This lesson assumes the multi-server patterns from the previous lesson, since you’ll
attach an observability handler at create_server() time, per server.
Step 1: The interface is smaller than you’d expect
McpObservabilityHandlerInterface (namespace
WP\MCP\Infrastructure\Observability\Contracts) defines exactly one method:
public function record_event( string $event, array $tags = array(), ?float $duration_ms = null ): void;
That’s the whole contract. The adapter calls record_event() at meaningful points in a
request’s lifecycle (server creation, permission checks, execution, failures), passing
an event name, a tags array with contextual data, and, where relevant, a duration in
milliseconds. Your job is deciding what to do with each call, log it, increment a
counter, ship it to an external metrics backend, whatever your infrastructure needs.
Step 2: A real handler, backed by WordPress transients
Here’s a minimal but genuinely useful handler that tracks call volume, per-ability counts, and error counts using transients, no external service required to get started:
// File: includes/class-my-plugin-observability-handler.php
declare( strict_types=1 );
use WP\MCP\Infrastructure\Observability\Contracts\McpObservabilityHandlerInterface;
class My_Plugin_Observability_Handler implements McpObservabilityHandlerInterface {
public function record_event( string $event, array $tags = array(), ?float $duration_ms = null ): void {
$bucket = 'my_plugin_mcp_metrics_' . gmdate( 'YmdH' ); // hourly bucket
$data = get_transient( $bucket ) ?: array( 'events' => array(), 'errors' => 0, 'total_ms' => 0.0, 'count' => 0 );
$data['events'][ $event ] = ( $data['events'][ $event ] ?? 0 ) + 1;
$data['count']++;
if ( null !== $duration_ms ) {
$data['total_ms'] += $duration_ms;
}
if ( str_contains( $event, 'failure' ) || str_contains( $event, 'error' ) ) {
$data['errors']++;
// Keep failure reasons for later triage.
$reason = $tags['failure_reason'] ?? 'unknown';
$data['failure_reasons'][ $reason ] = ( $data['failure_reasons'][ $reason ] ?? 0 ) + 1;
}
if ( isset( $tags['ability'] ) ) {
$data['by_ability'][ $tags['ability'] ] = ( $data['by_ability'][ $tags['ability'] ] ?? 0 ) + 1;
}
set_transient( $bucket, $data, HOUR_IN_SECONDS * 2 );
}
}
This is deliberately simple: hourly transient buckets keyed by event name, ability, and failure reason. It’s enough to answer “did errors spike this hour” and “which ability is getting hammered” without adding a database table. Swap the storage for an object cache write, a queued row insert, or an HTTP call to an external metrics service as your scale demands, the interface doesn’t care how you store the data.
Step 3: Use the adapter’s own failure taxonomy, don’t invent your own
The adapter defines a stable FailureReason class with constants like
PERMISSION_DENIED, EXECUTION_FAILED, EXECUTION_EXCEPTION, NOT_FOUND, and
INVALID_PARAMETER, and it passes these as the failure_reason tag on failure events.
Reading from that taxonomy instead of pattern-matching on error message strings gives
you metrics that stay stable even if the adapter’s internal error message wording
changes across releases:
if ( isset( $tags['failure_reason'] ) && \WP\MCP\Infrastructure\Observability\FailureReason::is_valid( $tags['failure_reason'] ) ) {
// Safe to bucket by $tags['failure_reason'] directly.
}
Step 4: Wire your handler into a server
Pass your class name, not an instance, into create_server() in the same slot where
the earlier lessons used NullMcpObservabilityHandler::class:
// File: my-plugin.php
add_action( 'mcp_adapter_init', function ( $adapter ) {
$adapter->create_server(
'support-readonly',
'my-plugin/support',
'mcp',
'Support Read-Only Server',
'Read-only ticket and KB abilities.',
'v1.0.0',
array( \WP\MCP\Transport\HttpTransport::class ),
\WP\MCP\Infrastructure\ErrorHandling\ErrorLogMcpErrorHandler::class,
My_Plugin_Observability_Handler::class,
array( 'my-plugin/list-tickets', 'my-plugin/get-ticket' )
);
} );
| Metric | Why it matters |
|---|---|
| Call volume per server | Tells you which server (and audience) is actually driving load, before you optimize the wrong one. |
| Latency (from duration_ms) | An agent calling a slow ability repeatedly is the classic MCP performance complaint, this is how you’d catch it. |
| Error rate by FailureReason | A spike in PERMISSION_DENIED often means a client misconfiguration, not an attack, but you need the data to tell them apart. |
| Per-ability usage | Reveals which abilities are load-bearing for real agent workflows, informing what you optimize or version carefully. |
Test it
Register the handler above on a test server, call one of its abilities a few times through an MCP client, then inspect the bucket directly:
wp-env run cli wp eval 'var_dump( get_transient( "my_plugin_mcp_metrics_" . gmdate("YmdH") ) );'
You should see count, by_ability, and (if you trigger a permission failure by
calling as a lower-privileged user) failure_reasons all populated.
It’s easy to assume ErrorLogMcpErrorHandler on the error-handling side means you
already have observability. It doesn’t, error logging and observability are two
separate interfaces (McpErrorHandlerInterface and
McpObservabilityHandlerInterface) with two separate default implementations. Logging
individual errors to error_log tells you an error happened; it doesn’t give you
volume, rate, or trend data. You need both, wired in deliberately.
Recap
McpObservabilityHandlerInterface has exactly one method, record_event(), called
with an event name, tags, and an optional duration. The adapter’s default,
NullMcpObservabilityHandler, does nothing on purpose. A real handler stores or ships
that data somewhere you can query it, bucketed by ability and by the adapter’s own
FailureReason taxonomy, and gets attached per server through create_server(), the
same call you used to register the server in the first place.
Resources & further reading
- MCP Adapter repository, GitHub
- MCP Adapter announcement: from abilities to AI agents, developer.wordpress.org
- Transients API, developer.wordpress.org