# xspeed/1.1.5/includes/modules/Mcp/Mcp_Tools.php

xSpeed Cache: AI-Powered Performance Hub with MCP, Caching &amp; CDN, version 1.1.5. 1,630 lines.

- Page: https://pluginprobe.com/plugins/xspeed/1.1.5/code/includes/modules/Mcp/Mcp_Tools.php
- Raw: https://pluginprobe.com/plugins/xspeed/1.1.5/raw/includes/modules/Mcp/Mcp_Tools.php
- Modified: 2026-08-11T13:46:42+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/xspeed/1.1.5/code/includes/modules/Mcp/Mcp_Tools.php#L10-L20`.

```php
<?php
/**
 * MCP tool registry — the single source of truth for the tools xSpeed
 * exposes to AI assistants.
 *
 * Each tool declares an MCP-style descriptor (name, description, JSON
 * Schema inputSchema) and a handler that runs against the Free engine.
 * Consumed by BOTH:
 *   - Mcp_Server (the per-site JSON-RPC endpoint at /xspeed/mcp), and
 *   - McpModule's REST tool routes (the optional hosted-broker path),
 * so the two transports can never drift.
 *
 * Handlers take an associative array of already-decoded arguments and
 * return either a plain array (serialized to JSON in the MCP result) or
 * a WP_Error (surfaced as an MCP tool error).
 *
 * The plugin adds ZERO cache logic here — every handler is a thin proxy
 * to Cache / Settings / Settings_Manager / Server / Admin / Pro_Audit /
 * Cache_Benchmark.
 *
 * @package XSpeed
 */

declare(strict_types=1);

namespace XSpeed\Modules\Mcp;

use XSpeed\Cache;
use XSpeed\Server;
use XSpeed\Admin;
use XSpeed\Settings;
use XSpeed\Settings_Manager;
use XSpeed\Pro_Audit;
use XSpeed\Cache_Benchmark;

defined( 'ABSPATH' ) || exit;

final class Mcp_Tools {

	/** Valid cache purge types. */
	public const PURGE_TYPES = array( 'all', 'page', 'assets', 'object', 'rest' );

	/**
	 * Per-call read-only override. Null means "defer to the pairing token's
	 * scope" (the JSON-RPC path that predates OAuth). true/false is set by
	 * Mcp_Server when an OAuth access token (with its own scope) authorized
	 * the request, so a read-only OAuth grant is enforced even though the
	 * pairing token may be read-write (or absent).
	 *
	 * @var bool|null
	 */
	private static $read_only_override = null;

	/**
	 * Set the active credential's read-only state for the current request.
	 * Passing null clears the override (back to the pairing-token default).
	 *
	 * @param bool|null $read_only Whether the active credential is read-only.
	 */
	public static function set_read_only_override( ?bool $read_only ): void {
		self::$read_only_override = $read_only;
	}

	/**
	 * Whether the active MCP credential is limited to read-only tools. Uses
	 * the per-call override when set, else the pairing token's scope.
	 */
	private static function is_read_only(): bool {
		if ( null !== self::$read_only_override ) {
			return self::$read_only_override;
		}
		return Mcp_Pairing::is_read_only();
	}

	/**
	 * Per-call `configure` grant. Writing credential/secret fields over MCP is
	 * gated on this and it is OFF by default — even a write-scoped connection
	 * cannot rewrite an API token or password unless it was granted the
	 * explicit `configure` scope. Null means "no per-call grant" (the pairing
	 * token / JSON-RPC path), where it falls back to a filter. (#116)
	 *
	 * @var bool|null
	 */
	private static $configure_override = null;

	/**
	 * Set whether the active credential may write secret fields (the OAuth
	 * `configure` scope). Passing null clears it back to the filter default.
	 *
	 * @param bool|null $can_configure Whether the credential carries `configure`.
	 */
	public static function set_configure_override( ?bool $can_configure ): void {
		self::$configure_override = $can_configure;
	}

	/**
	 * Whether the active MCP credential may write credential/secret fields.
	 * Uses the per-call override (OAuth `configure` scope) when set; otherwise
	 * the `xspeed_mcp_allow_credential_writes` filter, which defaults to false
	 * so credential writes are off by default on every connection — including
	 * the pairing token. A site owner who wants an agent to manage credentials
	 * opts in by returning true from that filter. (#116)
	 */
	public static function can_configure(): bool {
		if ( null !== self::$configure_override ) {
			return self::$configure_override;
		}
		/**
		 * Allow MCP connections to write credential (secret) fields. Off by
		 * default; see docs/MCP-SERVER.md. Applies to pairing-token connections
		 * and any OAuth grant lacking the `configure` scope.
		 *
		 * @param bool $allow Whether credential writes over MCP are permitted.
		 */
		return (bool) apply_filters( 'xspeed_mcp_allow_credential_writes', false );
	}

	/**
	 * Full tool catalog: name => descriptor. `handler` is a callable
	 * ( array $args ) : array|\WP_Error. `write` marks tools that mutate
	 * state (used for read-only scope enforcement).
	 *
	 * @return array<string, array{description:string, inputSchema:array, handler:callable, write:bool}>
	 */
	public static function catalog(): array {
		$catalog = array(
			'get_cache_status' => array(
				'description' => 'Get cache status for this WordPress site: whether caching is enabled, cache stats (cached pages, size, hit ratio, last purge), and the detected web server.',
				'inputSchema' => self::object_schema( array(), array() ),
				'write'       => false,
				'handler'     => array( self::class, 'get_cache_status' ),
			),
			'list_modules'     => array(
				'description' => 'List all xSpeed modules (free and Pro) with their settings schema and status.',
				'inputSchema' => self::object_schema( array(), array() ),
				'write'       => false,
				'handler'     => array( self::class, 'list_modules' ),
			),
			'run_benchmark'    => array(
				'description' => 'Run a before/after cache benchmark on the home page and return the timings. Each side reports bytes (decoded payload) and bytes_transferred (compressed wire size).',
				'inputSchema' => self::object_schema( array(), array() ),
				'write'       => false,
				'handler'     => array( self::class, 'run_benchmark' ),
			),
			'get_pro_audit'    => array(
				'description' => 'Personalized list of Pro features that would benefit THIS site, from its current settings and cache stats.',
				'inputSchema' => self::object_schema( array(), array() ),
				'write'       => false,
				'handler'     => array( self::class, 'get_pro_audit' ),
			),
			'purge_cache'      => array(
				'description' => 'Purge the site cache. "type" selects what to purge: all, page, assets, object, or rest. Defaults to all.',
				'inputSchema' => self::object_schema(
					array(
						'type' => array(
							'type'        => 'string',
							'enum'        => self::PURGE_TYPES,
							'description' => 'What to purge. Defaults to "all".',
						),
					),
					array()
				),
				'write'       => true,
				'handler'     => array( self::class, 'purge_cache' ),
			),
			'toggle_cache'     => array(
				'description' => 'Enable or disable page caching. Installs/removes the cache drop-in and WP_CACHE constant as needed.',
				'inputSchema' => self::object_schema(
					array(
						'enabled' => array(
							'type'        => 'boolean',
							'description' => 'true to enable caching, false to disable.',
						),
					),
					array( 'enabled' )
				),
				'write'       => true,
				'handler'     => array( self::class, 'toggle_cache' ),
			),
			'get_settings'     => array(
				'description' => 'Read the settings for a given xSpeed module (e.g. "minify", "gzip"). Returns schema-validated values.',
				'inputSchema' => self::object_schema(
					array(
						'module' => array(
							'type'        => 'string',
							'description' => 'The module slug, e.g. "minify".',
						),
					),
					array( 'module' )
				),
				'write'       => false,
				'handler'     => array( self::class, 'get_settings' ),
			),
			'update_settings'  => array(
				'description' => 'Update settings for a given xSpeed module. "values" is an object of setting keys to new values; unknown keys are stripped and invalid values rejected by the module schema.',
				'inputSchema' => self::object_schema(
					array(
						'module' => array(
							'type'        => 'string',
							'description' => 'The module slug, e.g. "minify".',
						),
						'values' => array(
							'type'        => 'object',
							'description' => 'Map of setting keys to new values.',
						),
					),
					array( 'module', 'values' )
				),
				'write'       => true,
				'handler'     => array( self::class, 'update_settings' ),
			),
			// --- Promoted high-value actions: dedicated typed tools so the AI
			// calls them directly (no run_command hop). Each is a thin wrapper
			// over Cli_Bridge, so Free tools can drive Pro actions (psi, ccss)
			// without a cross-repo class reference, and none can drift from the
			// CLI. ---
			'purge_cloudflare' => array(
				'description' => 'Purge the Cloudflare edge cache for this site (requires Cloudflare connected in the Cloudflare module).',
				'inputSchema' => self::object_schema( array(), array() ),
				'write'       => true,
				'handler'     => array( self::class, 'purge_cloudflare' ),
			),
			'scan_database'    => array(
				'description' => 'Scan the database for bloat (post revisions, auto-drafts, trashed posts, spam comments, expired transients, orphaned meta) without deleting anything.',
				'inputSchema' => self::object_schema( array(), array() ),
				'write'       => false,
				'handler'     => array( self::class, 'scan_database' ),
			),
			'clean_database'   => array(
				'description' => 'Clean database bloat. Removes the categories currently enabled in the Database module settings. Destructive — run scan_database first to preview.',
				'inputSchema' => self::object_schema( array(), array() ),
				'write'       => true,
				'handler'     => array( self::class, 'clean_database' ),
			),
			'flush_object_cache' => array(
				'description' => 'Flush the persistent object cache (Redis / Memcached), if enabled.',
				'inputSchema' => self::object_schema( array(), array() ),
				'write'       => true,
				'handler'     => array( self::class, 'flush_object_cache' ),
			),
			'start_preloader'  => array(
				'description' => 'Start the cache preloader — crawls the sitemap to warm the page cache in the background.',
				'inputSchema' => self::object_schema( array(), array() ),
				'write'       => true,
				'handler'     => array( self::class, 'start_preloader' ),
			),
			'run_pagespeed'    => array(
				'description' => 'Run an external performance audit (PageSpeed Insights, or GTmetrix when configured) and return the score + Core Web Vitals. Defaults to the site home page, mobile strategy. Requires external scores to be enabled in settings — the plugin makes no outbound calls otherwise.',
				'inputSchema' => self::object_schema(
					array(
						'url'      => array(
							'type'        => 'string',
							'description' => 'URL to audit. Defaults to the site home page.',
						),
						'strategy' => array(
							'type'        => 'string',
							'enum'        => array( 'mobile', 'desktop' ),
							'description' => 'Audit strategy. Defaults to "mobile".',
						),
						'force'    => array(
							'type'        => 'boolean',
							'description' => 'Re-run even when a recent cached result exists. Use after a change you want measured immediately.',
						),
					),
					array()
				),
				'write'       => false,
				'handler'     => array( self::class, 'run_pagespeed' ),
			),
			'generate_critical_css' => array(
				'description' => 'Generate above-the-fold Critical CSS for the site (Pro). Calls the external generator and stores the result.',
				'inputSchema' => self::object_schema( array(), array() ),
				'write'       => true,
				'handler'     => array( self::class, 'generate_critical_css' ),
			),
			'get_health'       => array(
				'description' => 'Full health diagnostics: every Health check (drop-in, WP_CACHE, server rewrite, expiry-vs-preload, Set-Cookie poisoning, conflicts), cache stats, hourly hit/miss buckets, the daily hit-ratio series, and recent activity. The single best first call when diagnosing a low hit ratio.',
				'inputSchema' => self::object_schema( array(), array() ),
				'write'       => false,
				'handler'     => array( self::class, 'get_health' ),
			),
			'get_benchmark_history' => array(
				'description' => 'Stored benchmark runs (oldest to newest: timestamps, uncached/cached ms, savings, transfer bytes) plus recent settings-change events for correlating a change with its performance effect.',
				'inputSchema' => self::object_schema(
					array(
						'limit' => array(
							'type'        => 'integer',
							'description' => 'Max runs to return (default 100).',
						),
					),
					array()
				),
				'write'       => false,
				'handler'     => array( self::class, 'get_benchmark_history' ),
			),
			'get_score_history' => array(
				'description' => 'Stored EXTERNAL audit runs (PageSpeed Insights / GTmetrix): score, Core Web Vitals (LCP/FCP/CLS/TBT/SI/TTFB), which tool ran it, and the report link where one exists. Read-only — returns what this site already measured and never starts a new audit. Use run_pagespeed to actually run one.',
				'inputSchema' => self::object_schema(
					array(
						'limit' => array(
							'type'        => 'integer',
							'description' => 'Max runs to return, newest first (default 100).',
						),
					),
					array()
				),
				'write'       => false,
				'handler'     => array( self::class, 'get_score_history' ),
			),
			// --- Actions promoted out of the generated `xspeed_*` aliases.
			// Each was previously reachable ONLY as an `action` string on a
			// coarse generated tool that was marked write regardless, so a
			// read-only connection lost the read ones. Typed here with an
			// honest kind so the AI stops guessing and the deny-list has one
			// name per action. ---
			'get_cache_inventory' => array(
				'description' => 'Inspect what is actually in the page cache: which pages are cached and how old they are, or where the disk usage goes. Read-only.',
				'inputSchema' => self::object_schema(
					array(
						'detail' => array(
							'type'        => 'string',
							'enum'        => array( 'pages', 'size' ),
							'description' => '"pages" lists cached pages and their age; "size" breaks down disk usage. Defaults to "pages".',
						),
						'limit'  => array(
							'type'        => 'string',
							'description' => 'Max rows to return (pages only).',
						),
					),
					array()
				),
				'write'       => false,
				'handler'     => array( self::class, 'get_cache_inventory' ),
			),
			'get_purge_log'    => array(
				'description' => 'Recent cache purges and what triggered each one. Use it to explain why a page stopped being cached. Read-only.',
				'inputSchema' => self::object_schema(
					array(
						'limit' => array(
							'type'        => 'string',
							'description' => 'Max entries to return.',
						),
					),
					array()
				),
				'write'       => false,
				'handler'     => array( self::class, 'get_purge_log' ),
			),
			'recheck_rewrite_rules' => array(
				'description' => 'Re-verify the server rewrite rules that route requests to the cache, and repair them if they drifted.',
				'inputSchema' => self::object_schema( array(), array() ),
				'write'       => true,
				'handler'     => array( self::class, 'recheck_rewrite_rules' ),
			),
			'set_cloudflare_dev_mode' => array(
				'description' => 'Turn Cloudflare development mode on or off. On bypasses the edge cache for ~3 hours so origin changes show immediately.',
				'inputSchema' => self::object_schema(
					array(
						'enabled' => array(
							'type'        => 'boolean',
							'description' => 'true turns development mode on, false turns it off.',
						),
					),
					array( 'enabled' )
				),
				'write'       => true,
				'handler'     => array( self::class, 'set_cloudflare_dev_mode' ),
			),
			'optimize_database' => array(
				'description' => 'Run table optimization on the WordPress database (reclaims space after cleanup). Separate from clean_database, which deletes bloat rows.',
				'inputSchema' => self::object_schema( array(), array() ),
				'write'       => true,
				'handler'     => array( self::class, 'optimize_database' ),
			),
			'get_object_cache_status' => array(
				'description' => 'Object cache state: whether the drop-in is installed, which backend is configured, and the server snippet needed to enable it. Read-only.',
				'inputSchema' => self::object_schema(
					array(
						'detail' => array(
							'type'        => 'string',
							'enum'        => array( 'status', 'snippet' ),
							'description' => '"status" reports the current state; "snippet" returns the server config to enable it. Defaults to "status".',
						),
					),
					array()
				),
				'write'       => false,
				'handler'     => array( self::class, 'get_object_cache_status' ),
			),
			'toggle_object_cache' => array(
				'description' => 'Enable or disable the object cache drop-in. Verify the backend with test_object_cache first — enabling against an unreachable server slows every request.',
				'inputSchema' => self::object_schema(
					array(
						'enabled' => array(
							'type'        => 'boolean',
							'description' => 'true installs the drop-in, false removes it.',
						),
					),
					array( 'enabled' )
				),
				'write'       => true,
				'handler'     => array( self::class, 'toggle_object_cache' ),
			),
			'manage_critical_css' => array(
				'description' => 'List the stored Critical CSS entries, or clear them so they regenerate. Use generate_critical_css to create them.',
				'inputSchema' => self::object_schema(
					array(
						'action' => array(
							'type'        => 'string',
							'enum'        => array( 'list', 'clear' ),
							'description' => '"list" returns what is stored; "clear" deletes it.',
						),
					),
					array( 'action' )
				),
				'write'       => true,
				'handler'     => array( self::class, 'manage_critical_css' ),
			),
			'get_preloader_status' => array(
				'description' => 'Cache preloader progress: whether a run is active, how far through the URL list it is. Read-only.',
				'inputSchema' => self::object_schema( array(), array() ),
				'write'       => false,
				'handler'     => array( self::class, 'get_preloader_status' ),
			),
			'stop_preloader'   => array(
				'description' => 'Stop a running cache preload. Safe mid-run — already-warmed pages stay cached.',
				'inputSchema' => self::object_schema( array(), array() ),
				'write'       => true,
				'handler'     => array( self::class, 'stop_preloader' ),
			),
			'purge_url'        => array(
				'description' => 'Purge the cache for ONE URL only (all its variants: device buckets, trailing-slash forms, static-tree copy). Surgical alternative to purge_cache when a single page changed.',
				'inputSchema' => self::object_schema(
					array(
						'url' => array(
							'type'        => 'string',
							'description' => 'Absolute URL or site-relative path, e.g. "https://site.com/about/" or "/about/".',
						),
					),
					array( 'url' )
				),
				'write'       => true,
				'handler'     => array( self::class, 'purge_url' ),
			),
			'test_object_cache' => array(
				'description' => 'Live connect + read/write probe of the configured Redis/Memcached backend using the saved Object Cache settings. Verifies the credentials actually work — writing settings alone does not.',
				'inputSchema' => self::object_schema( array(), array() ),
				'write'       => false,
				'handler'     => array( self::class, 'test_object_cache' ),
			),
			'cloudflare_verify' => array(
				'description' => 'Verify the saved Cloudflare credentials against the Cloudflare API (token/zone check). Read-only — use purge_cloudflare to purge the edge.',
				'inputSchema' => self::object_schema( array(), array() ),
				'write'       => false,
				'handler'     => array( self::class, 'cloudflare_verify' ),
			),
			'list_commands'    => array(
				'description' => 'List every xSpeed command that run_command can invoke (name, description, module, options). Use this to discover the full action surface beyond the curated + dedicated tools.',
				'inputSchema' => self::object_schema( array(), array() ),
				'write'       => false,
				'handler'     => array( self::class, 'list_commands' ),
			),
			'run_command'      => array(
				'description' => 'Run any xSpeed command — the full CLI surface (~50 commands across every module: cache, cloudflare, database, critical/unused CSS, pagespeed, images, migration, preloader, object cache, analytics, RUM, smart-* and more). Call list_commands first to discover names + options. Examples: run_command("cloudflare purge"), run_command("database clean"), run_command("psi", {}, {"url":"https://site.com","strategy":"mobile"}).',
				'inputSchema' => self::object_schema(
					array(
						'command' => array(
							'type'        => 'string',
							'description' => 'Command name, e.g. "cloudflare purge" or "database scan" (the "xspeed " prefix is optional).',
						),
						'args'    => array(
							'type'        => 'array',
							'description' => 'Positional arguments, if the command takes any.',
							'items'       => array( 'type' => 'string' ),
						),
						'options' => array(
							'type'        => 'object',
							'description' => 'Named options / flags, e.g. { "url": "https://site.com", "strategy": "mobile", "force": true }.',
						),
					),
					array( 'command' )
				),
				'write'       => true,
				'handler'     => array( self::class, 'run_command' ),
			),
		);

		// Dedicated tools that wrap a command only present when a given
		// module is active (e.g. Pro): drop them if the command isn't
		// registered, so we never advertise a tool that always fails. The
		// action stays reachable via run_command if the command exists.
		$conditional = array(
			'generate_critical_css' => 'xspeed ccss',
			'purge_cloudflare'      => 'xspeed cf',
			'cloudflare_verify'     => 'xspeed cf',
			'flush_object_cache'    => 'xspeed objcache',
			'test_object_cache'     => 'xspeed objcache',
			'start_preloader'       => 'xspeed preloader',
			'scan_database'         => 'xspeed db',
			'clean_database'        => 'xspeed db',
			'purge_url'             => 'xspeed cache',
			'get_cache_inventory'   => 'xspeed cache',
			'get_purge_log'         => 'xspeed cache',
			'recheck_rewrite_rules' => 'xspeed cache',
			'set_cloudflare_dev_mode' => 'xspeed cf',
			'optimize_database'     => 'xspeed db',
			'get_object_cache_status' => 'xspeed objcache',
			'toggle_object_cache'   => 'xspeed objcache',
			'manage_critical_css'   => 'xspeed ccss',
			'get_preloader_status'  => 'xspeed preloader',
			'stop_preloader'        => 'xspeed preloader',
		);

		/*
		 * Tools that are ALWAYS in the catalog, mapped to the command they
		 * cover. These are listed separately from $conditional because the
		 * two roles are different and used to be conflated in one map: this
		 * set only tells the alias generator "don't emit an alias for this
		 * command, a typed tool already covers it" — it must never drop a
		 * tool.
		 *
		 * That conflation is exactly what broke get_settings/update_settings:
		 * they were mapped to `xspeed settings`, a command that did not exist,
		 * so the drop loop unset them on every request and they never reached
		 * tools/list. `xspeed settings` now exists (SettingsModule), but the
		 * split is what stops the class of bug recurring — an unconditional
		 * tool can no longer be removed by a command going away. (#149/#153)
		 */
		$always = array(
			'get_settings'      => 'xspeed settings',
			'update_settings'   => 'xspeed settings',
			'run_pagespeed'     => 'xspeed psi',
			'get_health'        => 'xspeed health',
			'get_score_history' => 'xspeed score',
		);

		$commands = Cli_Bridge::commands();
		foreach ( $conditional as $tool => $command ) {
			if ( ! isset( $commands[ $command ] ) ) {
				unset( $catalog[ $tool ] );
			}
		}
		// Alias generation reads both maps; the drop loop above reads only
		// $conditional.
		$conditional = array_merge( $conditional, $always );

		/*
		 * One dedicated tool per xSpeed CLI command, generated from the same
		 * Cli_Bridge catalog the CLI registers from — so the AI can reach the
		 * long tail without the list_commands -> run_command hop, and the
		 * generated set can never drift from the CLI.
		 *
		 * Commands already covered by a typed tool above are SKIPPED. The
		 * `isset()` guard below only catches NAME collisions, and a generated
		 * name never collides — `xspeed cf` becomes `xspeed_cf`, which is not
		 * `purge_cloudflare`. So both used to ship: two tools for one action,
		 * with the generated one marked write even when it wrapped a read,
		 * and a per-tool permission on one name silently bypassable via the
		 * other. $conditional already maps every typed tool to its command;
		 * inverted, that IS the skip list.
		 */
		foreach ( self::cli_generated_tools( array_flip( $conditional ) ) as $name => $spec ) {
			if ( ! isset( $catalog[ $name ] ) ) {
				$catalog[ $name ] = $spec;
			}
		}

		return $catalog;
	}

	/**
	 * Generate one MCP tool per registered xSpeed CLI command. Each wraps
	 * Cli_Bridge::run(): the tool's `action` (the command's first positional,
	 * e.g. `verify`/`purge` for `xspeed cf`) plus any named options are passed
	 * straight through. Tool names are the command with the `xspeed ` prefix
	 * dropped and spaces -> underscores (`xspeed cf` -> `xspeed_cf`).
	 *
	 * @return array<string, array{description:string, inputSchema:array, write:bool, handler:callable}>
	 */
	private static function cli_generated_tools( array $covered = array() ): array {
		$tools = array();
		foreach ( Cli_Bridge::commands() as $command => $spec ) {
			// Already exposed as typed tools with real schemas and honest
			// read/write kinds — generating a coarse alias too would give the
			// AI two ways to do one thing and make a per-tool permission on
			// the typed name bypassable via the generated one.
			if ( isset( $covered[ $command ] ) ) {
				continue;
			}
			$tool_name = self::cli_tool_name( $command );
			if ( '' === $tool_name ) {
				continue;
			}

			// Build the input schema from the command's synopsis: positional
			// args become string properties (the first is usually the action,
			// exposed with its allowed values as an enum); assoc args become
			// named options.
			$properties = array();
			$required   = array();
			foreach ( $spec['synopsis'] as $arg ) {
				if ( ! isset( $arg['name'] ) ) {
					continue;
				}
				$arg_name = (string) $arg['name'];
				$prop     = array(
					'type'        => 'string',
					'description' => isset( $arg['description'] ) ? (string) $arg['description'] : '',
				);
				if ( isset( $arg['options'] ) && is_array( $arg['options'] ) && ! empty( $arg['options'] ) ) {
					$prop['enum'] = array_values( array_map( 'strval', $arg['options'] ) );
				}
				$properties[ $arg_name ] = $prop;
				$is_optional             = ! empty( $arg['optional'] );
				$is_flag                 = isset( $arg['type'] ) && 'flag' === $arg['type'];
				if ( ! $is_optional && ! $is_flag ) {
					$required[] = $arg_name;
				}
			}

			$description = '' !== $spec['shortdesc']
				? $spec['shortdesc']
				: sprintf( 'Run the "%s" xSpeed command.', $command );

			list( $write, $write_actions, $read_actions ) = self::cli_write_profile( $command, $spec['synopsis'] );

			$tools[ $tool_name ] = array(
				'description'   => $description,
				'inputSchema'   => self::object_schema( $properties, $required ),
				'write'         => $write,
				// The action values that mutate state. When set, read-only
				// enforcement is per-ACTION (a read-only grant may still call
				// the tool with a read action like "status"/"scan").
				'write_actions' => $write_actions,
				// The complement — actions positively classified as reads.
				// action_writes() allowlists against THIS rather than negating
				// write_actions, so an action added to a command later is
				// refused under a read-only grant until it has been
				// classified, instead of silently becoming callable.
				'read_actions'  => $read_actions,
				'handler'       => self::cli_handler_for( $command, $spec['synopsis'] ),
			);
		}
		return $tools;
	}

	/** Derive an MCP tool name from a CLI command ("xspeed cf" -> "xspeed_cf"). */
	private static function cli_tool_name( string $command ): string {
		$command = trim( preg_replace( '/\s+/', ' ', $command ) ?? '' );
		if ( '' === $command ) {
			return '';
		}
		return str_replace( ' ', '_', $command );
	}

	/** Action verbs that only inspect state (never mutate). */
	private const CLI_READ_VERBS = array( 'status', 'scan', 'list', 'verify', 'get', 'show', 'info', 'export', 'preview', 'check', 'snippet', 'test' );

	/** Commands with NO action enum that are nonetheless pure inspection. */
	private const CLI_READ_ONLY_COMMANDS = array( 'xspeed health', 'xspeed support' );

	/**
	 * Compute the write profile for a generated command tool:
	 *   [ $write_bool, $write_actions ]
	 * where $write_actions is the list of action values that mutate state
	 * (empty when the tool has no action enum). $write_bool is the tool-level
	 * flag: true if ANY action writes (so read-only clients see it flagged),
	 * but per-action enforcement in invoke() still lets a read-only grant run
	 * the tool's read actions (e.g. `minify status` while `minify purge` is
	 * refused).
	 *
	 * @param string $command  Full command name.
	 * @param array  $synopsis Command synopsis.
	 * @return array{0:bool,1:string[],2:string[]} write flag, write actions, read actions
	 */
	/**
	 * Does THIS call mutate state, given the action the caller submitted?
	 *
	 * The tool-level `write` flag is true when ANY of a command's actions
	 * write, so read-only clients can see the tool is capable of mutating.
	 * Enforcing on that flag alone refuses the whole tool — which is how a
	 * read-only grant lost the ability to run `xspeed_minify status` even
	 * though only `purge` writes. `write_actions` records exactly which
	 * action values mutate; this is what reads it.
	 *
	 * Fails CLOSED in every ambiguous case. An action that isn't in the
	 * schema, an absent action, or a tool with no per-action profile all fall
	 * back to the coarse flag and are refused. A read-only grant may end up
	 * with less access than strictly necessary; it must never end up with
	 * more.
	 *
	 * @param array $tool The catalog entry.
	 * @param array $args The submitted arguments.
	 */
	private static function action_writes( array $tool, array $args ): bool {
		$write_actions = isset( $tool['write_actions'] ) && is_array( $tool['write_actions'] )
			? $tool['write_actions']
			: array();

		// No per-action profile — the coarse flag is all we have.
		if ( empty( $write_actions ) ) {
			return true;
		}

		$action = isset( $args['action'] ) && is_scalar( $args['action'] )
			? strtolower( trim( (string) $args['action'] ) )
			: '';

		// No action supplied: the command's own default is unknown here, so
		// treat it as a write rather than guessing.
		if ( '' === $action ) {
			return true;
		}

		// Only an action we positively recognise as read is allowed through.
		// Anything unknown is refused, so a future action added to a command
		// can't silently become callable under a read-only grant before it has
		// been classified.
		$known = array_map(
			static function ( $a ) {
				return strtolower( trim( (string) $a ) );
			},
			isset( $tool['read_actions'] ) && is_array( $tool['read_actions'] ) ? $tool['read_actions'] : array()
		);

		return ! in_array( $action, $known, true );
	}

	private static function cli_write_profile( string $command, array $synopsis ): array {
		// Command with an action enum → classify each action.
		foreach ( $synopsis as $arg ) {
			if ( isset( $arg['type'], $arg['options'] ) && 'positional' === $arg['type'] && is_array( $arg['options'] ) ) {
				$write_actions = array();
				$read_actions  = array();
				foreach ( $arg['options'] as $opt ) {
					if ( in_array( strtolower( (string) $opt ), self::CLI_READ_VERBS, true ) ) {
						$read_actions[] = (string) $opt;
					} else {
						$write_actions[] = (string) $opt;
					}
				}
				return array( ! empty( $write_actions ), $write_actions, $read_actions );
			}
		}

		// No action enum: a small allow-list of pure-inspection commands is
		// read-only; everything else defaults to write (safe — a read-only
		// grant never mutates).
		$is_read = in_array( trim( $command ), self::CLI_READ_ONLY_COMMANDS, true );
		return array( ! $is_read, array(), array() );
	}

	/**
	 * Build the handler for a generated command tool. It maps the tool's
	 * arguments back to Cli_Bridge::run(): positional synopsis args (in order)
	 * become $args; everything else is passed as named options.
	 *
	 * @param string $command  Full command name.
	 * @param array  $synopsis Command synopsis.
	 * @return callable
	 */
	private static function cli_handler_for( string $command, array $synopsis ): callable {
		// Names of the positional args, in declared order.
		$positionals = array();
		foreach ( $synopsis as $arg ) {
			if ( isset( $arg['name'] ) && ( ! isset( $arg['type'] ) || 'positional' === $arg['type'] ) ) {
				$positionals[] = (string) $arg['name'];
			}
		}

		return static function ( array $tool_args ) use ( $command, $positionals ) {
			$args  = array();
			$assoc = $tool_args;
			// Pull positionals out (in order) into $args; the rest are options.
			foreach ( $positionals as $pname ) {
				if ( array_key_exists( $pname, $assoc ) && '' !== (string) $assoc[ $pname ] ) {
					$args[] = (string) $assoc[ $pname ];
				}
				unset( $assoc[ $pname ] );
			}
			return Cli_Bridge::run( $command, $args, $assoc );
		};
	}

	/**
	 * The tool list in MCP `tools/list` shape.
	 *
	 * @return array<int, array{name:string, description:string, inputSchema:array}>
	 */
	public static function list(): array {
		$out = array();
		foreach ( self::catalog() as $name => $spec ) {
			$out[] = array(
				'name'        => $name,
				'description' => $spec['description'],
				'inputSchema' => $spec['inputSchema'],
			);
		}
		return $out;
	}

	/**
	 * Invoke a tool by name with decoded arguments.
	 *
	 * @param string $name Tool name.
	 * @param array  $args Decoded arguments.
	 * @return array|\WP_Error Result payload or error.
	 */
	public static function invoke( string $name, array $args ) {
		$catalog = self::catalog();
		if ( ! isset( $catalog[ $name ] ) ) {
			$error = new \WP_Error(
				'xspeed_mcp_unknown_tool',
				sprintf(
					/* translators: %s: tool name. */
					__( 'Unknown tool: %s', 'xspeed' ),
					$name
				),
				array( 'status' => 404 )
			);

			// A call for a tool that doesn't exist is still something that
			// happened to this site, and a run of them is the shape of a
			// probe. Recording it is the difference between a trail that
			// shows what was ATTEMPTED and one that only shows what
			// succeeded. Scope is unknowable here, so log the conservative
			// one rather than implying the attempt was read-only.
			Mcp_Activity_Log::record( $name, $args, false, $error->get_error_message(), 'write', self::$channel );

			return $error;
		}

		// Scope enforcement: a read-only connection cannot invoke a tool that
		// mutates state. run_command is a gateway to the full CLI surface, so
		// it's treated as write regardless of the wrapped command. The active
		// credential's scope (pairing token OR OAuth access token) is carried
		// in self::$scope_override; it falls back to the pairing global for
		// callers that don't set a per-call scope.
		if ( ! empty( $catalog[ $name ]['write'] ) && self::is_read_only() && self::action_writes( $catalog[ $name ], $args ) ) {
			return new \WP_Error(
				'xspeed_mcp_read_only',
				sprintf(
					/* translators: %s: tool name. */
					__( 'This MCP connection is read-only; the "%s" tool changes state and is not permitted. Reconnect with write access to use it.', 'xspeed' ),
					$name
				),
				array( 'status' => 403 )
			);
		}

		self::$dispatching = true;
		try {
			$result = call_user_func( $catalog[ $name ]['handler'], $args );

			// Audit every dispatched call — this is the record the admin
			// reads to answer "what did the assistant do to my site?".
			// Recorded here (not per-handler) so a new tool is covered the
			// moment it joins the catalog.
			[ $ok, $error ] = self::outcome( $result );

			$scope = empty( $catalog[ $name ]['write'] ) ? 'read' : 'write';

			Mcp_Activity_Log::record( $name, $args, $ok, $error, $scope, self::$channel );

			return $result;
		} finally {
			self::$dispatching = false;
		}
	}

	/**
	 * Read success/failure out of a handler result.
	 *
	 * Two failure shapes reach here. A handler that validates its own
	 * input returns WP_Error. A handler that delegates to Cli_Bridge gets
	 * back an ARRAY carrying `ok => false` plus `error`, because a
	 * `WP_CLI::error()` inside the shim is a controlled failure rather
	 * than an exception. Reading only the first shape logged every failed
	 * command — a refused purge, a Cloudflare call with no credentials —
	 * as a success.
	 *
	 * @param mixed $result Handler return value.
	 * @return array{0:bool,1:string}
	 */
	private static function outcome( $result ): array {
		if ( is_wp_error( $result ) ) {
			return array( false, $result->get_error_message() );
		}

		if ( is_array( $result ) && array_key_exists( 'ok', $result ) && ! $result['ok'] ) {
			$error = isset( $result['error'] ) ? (string) $result['error'] : '';
			return array( false, '' === $error ? 'Command reported failure.' : $error );
		}

		return array( true, '' );
	}

	/** @var string Transport that carried the current call (for the audit log). */
	private static $channel = 'mcp';

	/**
	 * Name the transport for subsequent invokes — the JSON-RPC endpoint and
	 * the hosted-broker REST routes share this catalog, and the audit trail
	 * should say which one a call arrived on.
	 */
	public static function set_channel( string $channel ): void {
		self::$channel = '' === $channel ? 'mcp' : $channel;
	}

	/** @var bool True while an MCP tool handler is executing. */
	private static $dispatching = false;

	/**
	 * True while a tool call is being dispatched — lets deeper layers
	 * (e.g. the settings change-log) attribute a mutation to MCP.
	 */
	public static function in_dispatch(): bool {
		return self::$dispatching;
	}

	/*
	 * Handlers — thin proxies to the Free engine. Each takes decoded tool
	 * arguments and returns an array payload (or WP_Error on bad input).
	 */

	/**
	 * Cache status, stats, and detected server.
	 *
	 * @param array $args Unused.
	 * @return array
	 */
	public static function get_cache_status( array $args ) {
		unset( $args );
		$opts = Settings::get();
		return array(
			'cache_enabled' => (bool) ( $opts['cache_enabled'] ?? false ),
			'stats'         => Cache::get_stats(),
			'server'        => Server::type(),
		);
	}

	/**
	 * All registered module descriptors.
	 *
	 * @param array $args Unused.
	 * @return array
	 */
	public static function list_modules( array $args ) {
		unset( $args );
		return Admin::modules_payload();
	}

	/**
	 * Before/after cache benchmark timings.
	 *
	 * @param array $args Unused.
	 * @return array
	 */
	public static function run_benchmark( array $args ) {
		unset( $args );
		return Cache_Benchmark::run();
	}

	/**
	 * Personalized Pro-feature suggestions for this site.
	 *
	 * @param array $args Unused.
	 * @return array
	 */
	public static function get_pro_audit( array $args ) {
		unset( $args );
		return array( 'suggestions' => Pro_Audit::run() );
	}

	/**
	 * Purge the cache by type.
	 *
	 * @param array $args { type?:string } — one of PURGE_TYPES; default all.
	 * @return array|\WP_Error
	 */
	public static function purge_cache( array $args ) {
		$type = isset( $args['type'] ) ? (string) $args['type'] : 'all';
		if ( '' === $type ) {
			$type = 'all';
		}
		if ( ! in_array( $type, self::PURGE_TYPES, true ) ) {
			return new \WP_Error(
				'xspeed_mcp_bad_type',
				sprintf(
					/* translators: %s: comma-separated list of valid purge types. */
					__( 'Invalid purge type. Expected one of: %s', 'xspeed' ),
					implode( ', ', self::PURGE_TYPES )
				),
				array( 'status' => 400 )
			);
		}
		// Named source, not the default "manual": the purge log's whole job
		// is to let an admin see that the cache cleared because an assistant
		// asked, not because someone clicked.
		$count = Cache::purge_type( $type, __( 'AI assistant', 'xspeed' ) );
		return array(
			'purged' => $type,
			'count'  => $count,
			'stats'  => Cache::get_stats(),
		);
	}

	/**
	 * Enable or disable page caching.
	 *
	 * @param array $args { enabled:bool }.
	 * @return array|\WP_Error
	 */
	public static function toggle_cache( array $args ) {
		if ( ! array_key_exists( 'enabled', $args ) ) {
			return new \WP_Error(
				'xspeed_mcp_missing_enabled',
				__( 'The "enabled" parameter is required (true or false).', 'xspeed' ),
				array( 'status' => 400 )
			);
		}
		$enabled = rest_sanitize_boolean( $args['enabled'] );
		$install = Cache::toggle( $enabled );

		// Persist cache_enabled the same way the Free /cache/toggle route
		// does (class-rest-api.php:235) — Cache::toggle handles the drop-in
		// + wp-config; Settings owns the option flag.
		Settings::update( array( 'cache_enabled' => $enabled ) );

		return array(
			'cache_enabled' => $enabled,
			'install_state' => $install,
			'stats'         => Cache::get_stats(),
		);
	}

	/**
	 * Is this module reachable over MCP right now?
	 *
	 * Mirrors SettingsModule::module_reachable(). Registration is not
	 * enough: Module_Registry::available() only asks whether Pro is LOADED,
	 * not whether it is LICENSED, so an unlicensed Pro site had every Pro
	 * module readable and writable over MCP while the dashboard showed it
	 * locked — reachable by any agent holding a write token. (QA M2)
	 *
	 * The licence answer comes through the `xspeed_module_descriptor` filter
	 * Pro registers, so Free never names a Pro class. (NOT
	 * `xspeed_pro_licensed` — Pro only ever APPLIES that one as an override
	 * and nothing listens to it, so gating on it silently passed everything.)
	 * `license` is exempt for the same reason Pro exempts it: locking it
	 * would remove the only surface that can fix an expired licence.
	 */
	private static function settings_module_reachable( string $slug ): bool {
		$module = \XSpeed\Module_Registry::available()[ $slug ] ?? null;
		if ( ! $module ) {
			return false;
		}
		if ( \XSpeed\Module::TIER_PRO !== $module->tier() || 'license' === $slug ) {
			return true;
		}

		// Ask the SAME question the dashboard asks. `xspeed_pro_licensed` is
		// only ever APPLIED by Pro as an override hook — nothing registers it
		// — so calling it here returned the default `true` and gated nothing.
		// Pro DOES register `xspeed_module_descriptor`, and sets
		// `locked => 'license'` on every Pro entry when the licence is
		// inactive. Reusing that keeps one definition of "locked" instead of
		// a second one in Free that can drift from the panel. (QA M2)
		$entry = apply_filters(
			'xspeed_module_descriptor',
			array(
				'slug' => $slug,
				'tier' => $module->tier(),
			),
			$module
		);

		return empty( $entry['locked'] );
	}

	/**
	 * Read a module's schema-validated settings.
	 *
	 * @param array $args { module:string }.
	 * @return array|\WP_Error
	 */
	public static function get_settings( array $args ) {
		$module = isset( $args['module'] ) ? (string) $args['module'] : '';
		if ( '' === $module ) {
			return new \WP_Error(
				'xspeed_mcp_missing_module',
				__( 'The "module" parameter is required.', 'xspeed' ),
				array( 'status' => 400 )
			);
		}
		if ( ! self::settings_module_reachable( $module ) ) {
			return new \WP_Error(
				'xspeed_mcp_unknown_module',
				sprintf(
					/* translators: %s: module slug. */
					__( 'Unknown module "%s".', 'xspeed' ),
					$module
				),
				array( 'status' => 404 )
			);
		}
		return array(
			'module'   => $module,
			// Public view — secret fields masked. An MCP agent must never be able
			// to read stored credentials back in plaintext. (#115)
			'settings' => Settings_Manager::get_public( $module ),
		);
	}

	/**
	 * Update a module's settings (schema-validated).
	 *
	 * @param array $args { module:string, values:array }.
	 * @return array|\WP_Error
	 */
	public static function update_settings( array $args ) {
		$module = isset( $args['module'] ) ? (string) $args['module'] : '';
		$values = $args['values'] ?? null;
		if ( '' === $module ) {
			return new \WP_Error(
				'xspeed_mcp_missing_module',
				__( 'The "module" parameter is required.', 'xspeed' ),
				array( 'status' => 400 )
			);
		}
		if ( ! is_array( $values ) ) {
			return new \WP_Error(
				'xspeed_mcp_bad_values',
				__( 'The "values" parameter must be an object of setting keys.', 'xspeed' ),
				array( 'status' => 400 )
			);
		}
		if ( ! self::settings_module_reachable( $module ) ) {
			return new \WP_Error(
				'xspeed_mcp_unknown_module',
				sprintf(
					/* translators: %s: module slug. */
					__( 'Unknown module "%s".', 'xspeed' ),
					$module
				),
				array( 'status' => 404 )
			);
		}
		// Writing credentials over MCP requires the explicit `configure` grant —
		// off by default even for a write-scoped connection — so an agent can't
		// silently repoint the Cloudflare/object-cache backend at an attacker
		// endpoint. Refuse with a message naming exactly which fields need it.
		// (Settings_Manager::update also strips these as a backstop covering the
		// run_command → CLI path.) (#116)
		if ( ! self::can_configure() ) {
			$secret_fields = Settings_Manager::secret_keys_in( $module, $values );
			if ( ! empty( $secret_fields ) ) {
				return new \WP_Error(
					'xspeed_mcp_configure_required',
					sprintf(
						/* translators: 1: comma-separated field names, 2: module slug. */
						__( 'Writing credential fields (%1$s) on "%2$s" needs the "configure" scope, which is off by default. Reconnect the MCP client granting the configure scope, or set these credentials from the xSpeed dashboard.', 'xspeed' ),
						implode( ', ', $secret_fields ),
						$module
					),
					array(
						'status'         => 403,
						'refused_fields' => $secret_fields,
					)
				);
			}
		}
		return array(
			'module'   => $module,
			// Return value is already masked (Settings_Manager::update returns the
			// public view), so a written secret isn't echoed back either. (#115)
			'settings' => Settings_Manager::update( $module, $values ),
		);
	}

	/**
	 * List every command run_command can invoke (the full CLI surface).
	 *
	 * @param array $args Unused.
	 * @return array
	 */
	public static function list_commands( array $args ) {
		unset( $args );
		return array( 'commands' => Cli_Bridge::catalog() );
	}

	/**
	 * Run any registered xSpeed command via the CLI bridge.
	 *
	 * @param array $args { command:string, args?:array, options?:array }.
	 * @return array|\WP_Error
	 */
	public static function run_command( array $args ) {
		$command = isset( $args['command'] ) ? (string) $args['command'] : '';
		if ( '' === $command ) {
			return new \WP_Error(
				'xspeed_mcp_missing_command',
				__( 'The "command" parameter is required.', 'xspeed' ),
				array( 'status' => 400 )
			);
		}
		$positional = isset( $args['args'] ) && is_array( $args['args'] ) ? $args['args'] : array();
		$options    = isset( $args['options'] ) && is_array( $args['options'] ) ? $args['options'] : array();
		return Cli_Bridge::run( $command, $positional, $options );
	}

	/* --------------------------------------------------------------------- */
	/* Promoted action handlers — typed wrappers over Cli_Bridge.            */
	/* Delegating to the bridge lets a Free tool drive a Pro action (psi,    */
	/* ccss) with no cross-repo class reference, and keeps zero drift.       */
	/* --------------------------------------------------------------------- */

	/**
	 * Purge the Cloudflare edge cache.
	 *
	 * @param array $args Unused.
	 * @return array|\WP_Error
	 */
	public static function purge_cloudflare( array $args ) {
		unset( $args );
		return Cli_Bridge::run( 'cf', array( 'purge' ) );
	}

	/**
	 * Scan the database for bloat (no deletion).
	 *
	 * @param array $args Unused.
	 * @return array|\WP_Error
	 */
	public static function scan_database( array $args ) {
		unset( $args );
		return Cli_Bridge::run( 'db', array( 'scan' ) );
	}

	/**
	 * Clean database bloat (destructive).
	 *
	 * @param array $args Unused.
	 * @return array|\WP_Error
	 */
	public static function clean_database( array $args ) {
		unset( $args );
		return Cli_Bridge::run( 'db', array( 'clean' ) );
	}

	/**
	 * Flush the persistent object cache.
	 *
	 * @param array $args Unused.
	 * @return array|\WP_Error
	 */
	public static function flush_object_cache( array $args ) {
		unset( $args );
		return Cli_Bridge::run( 'objcache', array( 'flush' ) );
	}

	/**
	 * Start the cache preloader.
	 *
	 * @param array $args Unused.
	 * @return array|\WP_Error
	 */
	public static function start_preloader( array $args ) {
		unset( $args );
		return Cli_Bridge::run( 'preloader', array( 'start' ) );
	}

	/**
	 * Full health diagnostics (checks + stats + buckets + activity).
	 * Direct typed payload — same tier as get_cache_status — so the agent
	 * gets structured tones/ids instead of parsing CLI log lines.
	 *
	 * @param array $args Unused.
	 * @return array
	 */
	public static function get_health( array $args ) {
		unset( $args );
		return array(
			'checks'    => \XSpeed\Health::checks(),
			'stats'     => Cache::get_stats(),
			'buckets'   => \XSpeed\Hit_Counter::buckets(),
			'hit_daily' => \XSpeed\Hit_Counter::daily_series( 30 ),
			'activity'  => \XSpeed\Activity_Log::entries(),
		);
	}

	/**
	 * Stored benchmark runs + settings-change events (trend data).
	 *
	 * @param array $args { limit?:int }.
	 * @return array
	 */
	public static function get_benchmark_history( array $args ) {
		$limit   = isset( $args['limit'] ) ? max( 1, min( 100, (int) $args['limit'] ) ) : 100;
		$changes = array();
		foreach ( \XSpeed\Activity_Log::entries() as $entry ) {
			if ( 'settings_changed' === ( $entry['type'] ?? '' ) ) {
				$changes[] = array(
					'ts'      => (int) $entry['ts'],
					'message' => (string) $entry['message'],
				);
			}
		}
		return array(
			'runs'    => Cache_Benchmark::history( $limit ),
			'changes' => $changes,
		);
	}

	/**
	 * Purge a single URL's cache entries.
	 *
	 * @param array $args { url:string }.
	 * @return array|\WP_Error
	 */
	/**
	 * Inspect what is in the page cache (pages + age, or size breakdown).
	 *
	 * @param array $args detail: pages|size, limit.
	 * @return array|\WP_Error
	 */
	public static function get_cache_inventory( array $args ) {
		$detail = isset( $args['detail'] ) ? (string) $args['detail'] : 'pages';
		$action = 'size' === $detail ? 'size' : 'inventory';
		$assoc  = array();
		if ( isset( $args['limit'] ) && '' !== $args['limit'] ) {
			$assoc['limit'] = (string) $args['limit'];
		}
		return Cli_Bridge::run( 'cache', array( $action ), $assoc );
	}

	/**
	 * Recent cache purges and their causes.
	 *
	 * @param array $args limit.
	 * @return array|\WP_Error
	 */
	public static function get_purge_log( array $args ) {
		$assoc = array();
		if ( isset( $args['limit'] ) && '' !== $args['limit'] ) {
			$assoc['limit'] = (string) $args['limit'];
		}
		return Cli_Bridge::run( 'cache', array( 'purge-log' ), $assoc );
	}

	/**
	 * Re-verify (and repair) the server rewrite rules.
	 *
	 * @param array $args Unused.
	 * @return array|\WP_Error
	 */
	public static function recheck_rewrite_rules( array $args ) {
		unset( $args );
		return Cli_Bridge::run( 'cache', array( 'recheck-rewrite' ) );
	}

	/**
	 * Turn Cloudflare development mode on or off.
	 *
	 * A boolean rather than two tools: dev-on and dev-off are one decision,
	 * and offering them separately doubles the surface for no gain.
	 *
	 * @param array $args enabled (bool, required).
	 * @return array|\WP_Error
	 */
	public static function set_cloudflare_dev_mode( array $args ) {
		if ( ! array_key_exists( 'enabled', $args ) ) {
			return new \WP_Error( 'xspeed_mcp_missing_enabled', __( 'The enabled argument is required.', 'xspeed' ), array( 'status' => 400 ) );
		}
		$on = filter_var( $args['enabled'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE );
		if ( null === $on ) {
			return new \WP_Error( 'xspeed_mcp_invalid_enabled', __( 'The enabled argument must be true or false.', 'xspeed' ), array( 'status' => 400 ) );
		}
		return Cli_Bridge::run( 'cf', array( $on ? 'dev-on' : 'dev-off' ) );
	}

	/**
	 * Optimize database tables (distinct from clean_database, which deletes).
	 *
	 * @param array $args Unused.
	 * @return array|\WP_Error
	 */
	public static function optimize_database( array $args ) {
		unset( $args );
		return Cli_Bridge::run( 'db', array( 'optimize' ) );
	}

	/**
	 * Object cache state, or the server snippet that enables it.
	 *
	 * @param array $args detail: status|snippet.
	 * @return array|\WP_Error
	 */
	public static function get_object_cache_status( array $args ) {
		$detail = isset( $args['detail'] ) ? (string) $args['detail'] : 'status';
		$action = 'snippet' === $detail ? 'snippet' : 'status';
		return Cli_Bridge::run( 'objcache', array( $action ) );
	}

	/**
	 * Install or remove the object-cache drop-in.
	 *
	 * @param array $args enabled (bool, required).
	 * @return array|\WP_Error
	 */
	public static function toggle_object_cache( array $args ) {
		if ( ! array_key_exists( 'enabled', $args ) ) {
			return new \WP_Error( 'xspeed_mcp_missing_enabled', __( 'The enabled argument is required.', 'xspeed' ), array( 'status' => 400 ) );
		}
		$on = filter_var( $args['enabled'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE );
		if ( null === $on ) {
			return new \WP_Error( 'xspeed_mcp_invalid_enabled', __( 'The enabled argument must be true or false.', 'xspeed' ), array( 'status' => 400 ) );
		}
		return Cli_Bridge::run( 'objcache', array( $on ? 'enable' : 'disable' ) );
	}

	/**
	 * List or clear stored Critical CSS.
	 *
	 * @param array $args action: list|clear.
	 * @return array|\WP_Error
	 */
	public static function manage_critical_css( array $args ) {
		$action = isset( $args['action'] ) ? (string) $args['action'] : '';
		if ( ! in_array( $action, array( 'list', 'clear' ), true ) ) {
			return new \WP_Error( 'xspeed_mcp_invalid_action', __( 'The action argument must be "list" or "clear".', 'xspeed' ), array( 'status' => 400 ) );
		}
		return Cli_Bridge::run( 'ccss', array( $action ) );
	}

	/**
	 * Preloader progress.
	 *
	 * @param array $args Unused.
	 * @return array|\WP_Error
	 */
	public static function get_preloader_status( array $args ) {
		unset( $args );
		return Cli_Bridge::run( 'preloader', array( 'status' ) );
	}

	/**
	 * Stop a running preload.
	 *
	 * @param array $args Unused.
	 * @return array|\WP_Error
	 */
	public static function stop_preloader( array $args ) {
		unset( $args );
		return Cli_Bridge::run( 'preloader', array( 'stop' ) );
	}

	/**
	 * Stored external audit runs (PSI / GTmetrix).
	 *
	 * Read-only by construction: it reads the option Score already wrote. No
	 * outbound call is made, which is what lets the Hub poll this on a
	 * schedule without spending the site owner's PSI or GTmetrix quota.
	 *
	 * @param array $args limit.
	 * @return array|\WP_Error
	 */
	public static function get_score_history( array $args ) {
		if ( ! class_exists( '\\XSpeed\\Score' ) ) {
			return new \WP_Error( 'xspeed_mcp_no_score', __( 'External scores are not available on this site.', 'xspeed' ), array( 'status' => 404 ) );
		}

		$limit = isset( $args['limit'] ) ? (int) $args['limit'] : 100;
		$limit = max( 1, min( 500, $limit ) );

		$runs = array();
		foreach ( array_slice( \XSpeed\Score::history(), 0, $limit ) as $run ) {
			if ( ! is_array( $run ) ) {
				continue;
			}
			$metrics = isset( $run['metrics'] ) && is_array( $run['metrics'] ) ? $run['metrics'] : array();
			$runs[]  = array(
				'provider'   => isset( $run['provider'] ) ? (string) $run['provider'] : 'unknown',
				'ts'         => isset( $run['ts'] ) ? (int) $run['ts'] : 0,
				'url'        => isset( $run['url'] ) ? (string) $run['url'] : '',
				'strategy'   => isset( $run['strategy'] ) ? (string) $run['strategy'] : null,
				// Null, never 0: Score distinguishes "no score" from "scored
				// zero", and flattening that reports a failed audit as a
				// catastrophic result.
				'score'      => isset( $run['score'] ) && is_numeric( $run['score'] ) ? (int) $run['score'] : null,
				'metrics'    => array(
					'lcp'  => self::metric_or_null( $metrics, 'lcp' ),
					'fcp'  => self::metric_or_null( $metrics, 'fcp' ),
					'cls'  => self::metric_or_null( $metrics, 'cls' ),
					'tbt'  => self::metric_or_null( $metrics, 'tbt' ),
					'si'   => self::metric_or_null( $metrics, 'si' ),
					'ttfb' => self::metric_or_null( $metrics, 'ttfb' ),
				),
				'report_url' => self::report_url_for( $run ),
			);
		}

		return array(
			'runs'  => $runs,
			'total' => count( \XSpeed\Score::history() ),
		);
	}

	/**
	 * One metric as a float, or null when absent/non-numeric.
	 *
	 * @param array  $metrics Metric bag.
	 * @param string $key     Metric id.
	 */
	private static function metric_or_null( array $metrics, string $key ): ?float {
		return isset( $metrics[ $key ] ) && is_numeric( $metrics[ $key ] ) ? (float) $metrics[ $key ] : null;
	}

	/**
	 * Deep link to the provider's own report, when one exists.
	 *
	 * GTmetrix hosts a durable report per test, so its id is enough to build
	 * the link. PSI does NOT — a Lighthouse result is returned to the caller
	 * and never hosted, so there is genuinely nothing to link to and this
	 * returns null rather than inventing a URL that 404s.
	 *
	 * @param array $run One stored run.
	 */
	private static function report_url_for( array $run ): ?string {
		$provider = isset( $run['provider'] ) ? (string) $run['provider'] : '';
		if ( 'gtmetrix' !== $provider ) {
			return null;
		}
		$test_id = isset( $run['test_id'] ) ? trim( (string) $run['test_id'] ) : '';
		if ( '' === $test_id ) {
			return null;
		}
		return 'https://gtmetrix.com/reports/' . rawurlencode( $test_id );
	}

	public static function purge_url( array $args ) {
		$url = isset( $args['url'] ) ? trim( (string) $args['url'] ) : '';
		if ( '' === $url ) {
			return new \WP_Error( 'xspeed_mcp_missing_url', __( 'The url argument is required.', 'xspeed' ), array( 'status' => 400 ) );
		}
		return Cli_Bridge::run( 'cache', array( 'purge-url', $url ), array( 'cause' => __( 'AI assistant', 'xspeed' ) ) );
	}

	/**
	 * Probe the configured object-cache backend (connect + read/write).
	 *
	 * @param array $args Unused.
	 * @return array|\WP_Error
	 */
	public static function test_object_cache( array $args ) {
		unset( $args );
		return Cli_Bridge::run( 'objcache', array( 'test' ) );
	}

	/**
	 * Verify the saved Cloudflare credentials.
	 *
	 * @param array $args Unused.
	 * @return array|\WP_Error
	 */
	public static function cloudflare_verify( array $args ) {
		unset( $args );
		return Cli_Bridge::run( 'cf', array( 'verify' ) );
	}

	/**
	 * Run a PageSpeed Insights audit (Pro).
	 *
	 * @param array $args { url?:string, strategy?:string }.
	 * @return array|\WP_Error
	 */
	public static function run_pagespeed( array $args ) {
		$options = array();
		if ( ! empty( $args['url'] ) ) {
			$options['url'] = (string) $args['url'];
		}
		if ( ! empty( $args['strategy'] ) ) {
			$options['strategy'] = (string) $args['strategy'];
		}
		// Was reachable only via the generated xspeed_psi alias, which this
		// change removes — so it moves onto the typed tool rather than being
		// lost with it.
		if ( ! empty( $args['force'] ) && filter_var( $args['force'], FILTER_VALIDATE_BOOLEAN ) ) {
			$options['force'] = true;
		}

		// Prefer the richer Pro engine when it's installed; otherwise drive
		// Free's own score command. Same tool name either way — an assistant
		// asking for a PageSpeed audit shouldn't have to know which tier the
		// site runs, and the two write to the same run history.
		if ( isset( Cli_Bridge::commands()['xspeed psi'] ) ) {
			return Cli_Bridge::run( 'psi', array(), $options );
		}
		return Cli_Bridge::run( 'score', array( 'run' ), $options );
	}

	/**
	 * Generate Critical CSS (Pro).
	 *
	 * @param array $args Unused.
	 * @return array|\WP_Error
	 */
	public static function generate_critical_css( array $args ) {
		unset( $args );
		return Cli_Bridge::run( 'ccss', array( 'generate' ) );
	}

	/**
	 * Build a JSON Schema object node.
	 *
	 * @param array    $properties Property map.
	 * @param string[] $required   Required property names.
	 */
	private static function object_schema( array $properties, array $required ): array {
		$schema = array(
			'type'       => 'object',
			'properties' => (object) $properties,
		);
		if ( ! empty( $required ) ) {
			$schema['required'] = array_values( $required );
		}
		return $schema;
	}
}

```
