Operating at Scale

Observability and Monitoring for MCP Servers

⏱ 16 min

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.

What you'll learn in this lesson
The real McpObservabilityHandlerInterface contract
Its one method, record_event(), and exactly what it receives.
What metrics actually matter for an MCP server
Call volume, latency, error rate, and per-ability usage, not vanity counters.
The FailureReason taxonomy
The adapter's own vocabulary for why a call failed, so your metrics stay consistent.
Wiring your handler into a real server
Passing your class into create_server() instead of the null default.
Prerequisites

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' )
	);
} );
Metrics worth tracking, and why
MetricWhy it matters
Call volume per serverTells 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 FailureReasonA spike in PERMISSION_DENIED often means a client misconfiguration, not an attack, but you need the data to tell them apart.
Per-ability usageReveals 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.

The default is silence, not logging

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

← Multi-Server Architectures at Scale Performance Optimization Under Agent Traffic →