Observability & Control

Building Your Own Audit Log for Agent Actions

⏱ 15 min

There is no admin screen in the MCP Adapter showing “which agent called what tool, when, and with what result.” The adapter ships an error handler (ErrorLogMcpErrorHandler, writing failures to WordPress’s error log) and an observability extension point (McpObservabilityHandlerInterface, whose default implementation, NullMcpObservabilityHandler, records nothing at all). Neither is an audit trail. If you need one, and for production or enterprise use you should, you build it yourself. This lesson covers two real, workable approaches.

What you'll learn in this lesson
Why nothing is recorded by default
ErrorLogMcpErrorHandler covers errors only, NullMcpObservabilityHandler records nothing.
Wrapping execute_callback yourself
The most reliable way to capture exactly which ability, which user, which arguments, every time.
A custom database table to store it
A real dbDelta schema you can add to any plugin.
Wiring a custom McpObservabilityHandlerInterface implementation
The adapter's own extension point, for lower-level, adapter-wide metrics.
Prerequisites

A working MCP server with at least one registered ability (Course 2). Comfort writing a WordPress plugin activation hook and using $wpdb.

Step 1: What’s genuinely built in, and what isn’t

ErrorLogMcpErrorHandler
The default McpErrorHandlerInterface implementation. Writes failures to the PHP error log with context, errors only, not successful calls.
NullMcpObservabilityHandler
The default McpObservabilityHandlerInterface implementation. By design, records absolutely nothing.
No database table, no admin screen
There is no shipped feature that persists "who called what tool" anywhere queryable.
Do not assume logging exists just because error handling does

It’s an easy mistake to see ErrorLogMcpErrorHandler in the adapter and assume successful, legitimate tool calls are being tracked somewhere too. They aren’t. A successful, fully-permitted “delete this post” call from an agent leaves no built-in trace at all unless you add one.

Step 2: The most reliable approach, wrap execute_callback yourself

Because you already write every ability’s execute_callback, wrapping it gives you exact control over what gets recorded, the ability name, the authenticated user, the arguments, the result, and the timing, all in one place:

// File: my-plugin.php
function my_plugin_audited_execute( string $ability_name, callable $callback ): callable {
	return function ( $input ) use ( $ability_name, $callback ) {
		$start  = microtime( true );
		$user   = get_current_user_id();
		$result = null;
		$error  = null;

		try {
			$result = $callback( $input );
		} catch ( \Throwable $e ) {
			$error = $e->getMessage();
			throw $e;
		} finally {
			my_plugin_log_agent_action( array(
				'ability'     => $ability_name,
				'user_id'     => $user,
				'input'       => wp_json_encode( $input ),
				'error'       => $error,
				'duration_ms' => round( ( microtime( true ) - $start ) * 1000, 2 ),
			) );
		}

		return $result;
	};
}

Then wrap it at registration time, around whatever the real logic already is:

// File: my-plugin.php
add_action( 'wp_abilities_api_init', function () {
	wp_register_ability(
		'my-plugin/update-own-draft',
		array(
			// ...label, input_schema, permission_callback as before...
			'execute_callback' => my_plugin_audited_execute(
				'my-plugin/update-own-draft',
				function ( $input ) {
					wp_update_post( array( 'ID' => $input['post_id'], 'post_content' => $input['content'] ) );
					return array( 'updated' => true );
				}
			),
		)
	);
} );

Every call, successful or not, now produces exactly one log entry with the fields your own audit needs, without depending on any adapter-internal event structure.

Step 3: A real table to store it in

// File: my-plugin.php
function my_plugin_create_audit_table() {
	global $wpdb;
	$table   = $wpdb->prefix . 'ai_agent_audit_log';
	$charset = $wpdb->get_charset_collate();

	require_once ABSPATH . 'wp-admin/includes/upgrade.php';
	dbDelta( "CREATE TABLE {$table} (
		id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
		ability VARCHAR(191) NOT NULL,
		user_id BIGINT UNSIGNED NOT NULL,
		input LONGTEXT NULL,
		error TEXT NULL,
		duration_ms FLOAT NULL,
		created_at DATETIME NOT NULL,
		PRIMARY KEY (id),
		KEY ability (ability),
		KEY user_id (user_id)
	) {$charset};" );
}
register_activation_hook( __FILE__, 'my_plugin_create_audit_table' );

function my_plugin_log_agent_action( array $entry ) {
	global $wpdb;
	$wpdb->insert( $wpdb->prefix . 'ai_agent_audit_log', array(
		'ability'     => $entry['ability'],
		'user_id'     => $entry['user_id'],
		'input'       => $entry['input'],
		'error'       => $entry['error'],
		'duration_ms' => $entry['duration_ms'],
		'created_at'  => current_time( 'mysql' ),
	) );
}
Do not log raw sensitive arguments unredacted

The same care from Lesson 6 applies here. If an ability’s input includes anything sensitive, redact it before it’s written into wp_json_encode( $input ), an audit log that itself stores unredacted PII is a new place that data can leak from.

Step 4: Wiring the adapter’s own observability handler too

The MCP Adapter’s McpObservabilityHandlerInterface gives you a second, complementary layer, lower-level events from the adapter’s own request routing, useful for metrics like request volume and duration across your whole server, not just individual abilities you’ve wrapped:

// File: my-plugin.php
class My_Plugin_Observability_Handler implements McpObservabilityHandlerInterface {
	public function record_event( string $event, array $tags = array(), ?float $duration_ms = null ): void {
		global $wpdb;
		$wpdb->insert( $wpdb->prefix . 'ai_agent_audit_log', array(
			'ability'     => $event,
			'user_id'     => get_current_user_id(),
			'input'       => wp_json_encode( $tags ),
			'duration_ms' => $duration_ms,
			'created_at'  => current_time( 'mysql' ),
		) );
	}
}

add_filter( 'mcp_adapter_default_server_config', function ( $config ) {
	$config['observability_handler'] = My_Plugin_Observability_Handler::class;
	return $config;
} );
Confirm exactly which events and tags your adapter version emits

The interface’s method signature, record_event( string $event, array $tags = [], ?float $duration_ms = null ), is stable, but exactly which event names and tag keys the adapter’s request router passes in can evolve between adapter versions. During development, temporarily error_log() the raw $event and $tags your handler receives so you know precisely what’s available before building dashboards or alerts on top of specific tag keys.

Test it: confirm entries actually land

wp-env run cli wp eval '
global $wpdb;
$rows = $wpdb->get_results( "SELECT ability, user_id, duration_ms, created_at FROM {$wpdb->prefix}ai_agent_audit_log ORDER BY id DESC LIMIT 5" );
print_r( $rows );
'

Trigger an ability call from your connected AI client, then re-run the query. You should see a new row with the correct ability name and user ID.

Recap

Nothing built into the MCP Adapter records successful agent tool calls. Wrapping your own execute_callback functions gives you the most precise, reliable audit trail, since you control exactly what gets logged, and a custom McpObservabilityHandlerInterface implementation adds a complementary, adapter-level layer for broader request metrics. Store both in a real database table, and redact sensitive arguments before they’re written, the same discipline as everywhere else in this course.

Resources & further reading

← Preventing Prompt Injection in Agent Workflows Managing and Revoking Agent Access →