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

xSpeed Cache: AI-Powered Performance Hub with MCP, Caching &amp; CDN, version 1.4.1. 2,961 lines.

- Page: https://pluginprobe.com/plugins/xspeed/1.4.1/code/includes/modules/Mcp/Mcp_Tools.php
- Raw: https://pluginprobe.com/plugins/xspeed/1.4.1/raw/includes/modules/Mcp/Mcp_Tools.php
- Modified: 2026-10-07T09:32:52+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.4.1/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\Module_Registry;
use XSpeed\Pro_Audit;
use XSpeed\Cache_Benchmark;
use XSpeed\Tier_Registry;
use XSpeed\Database_Cleaner;

defined( 'ABSPATH' ) || exit;

final class Mcp_Tools {
	/** Pro extension contract supported by this Free build. */
	public const EXTENSION_API = 1;

	/** Raw broker tool envelope cap, enforced before JSON decoding. */
	public const MAX_TOOL_BODY_BYTES = 2 * 1024 * 1024;

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

	/**
	 * 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' ),
			),
			'get_site_info'    => array(
				'description' => 'Get facts about this site and install: whether xSpeed Pro is active and licensed, plugin/WordPress/PHP versions, and the detected web server. Use this rather than inferring the tier from the module list. `addons` maps each separately licensed add-on to `{licensed, status}`. `licensed` says whether the add-on\'s licence is active. `status` is the licence status the site has stored (`valid`, `expired`, `inactive`, and so on), or null when no key is stored. Both are read from stored state, never from the licence server. `addons` is `{}` when no add-on reports. A licensed add-on may still have nothing set up.',
				'inputSchema' => self::object_schema( array(), array() ),
				'write'       => false,
				'handler'     => array( self::class, 'get_site_info' ),
			),
			'optimize_site'    => array(
				'description' => 'Make this site faster, end to end: measure, apply the recommended settings ONE AT A TIME, check the page still renders after each, and undo any change that breaks it. Returns what was applied, the site\'s performance score, `next_steps` (riskier settings that could help but are NOT applied automatically), and `unfixable` (problems no caching plugin can reach). ALWAYS relay all three to the user: report the score and what is still wrong, then — if `next_steps` is non-empty — describe each one WITH its stated `risk` and ASK whether to run again with aggressiveness "aggressive". Never enable aggressive settings without the user agreeing first, and never present `unfixable` items as things you can solve; they need the site owner or the host. A site where nothing was left to do is a real, good answer — say so plainly rather than apologising or retrying. Use `dry_run` to preview the plan. The `score` object carries `age_seconds` and `stale`: quote the score WITH how recently it was measured, and never describe a stale score as the result this run produced — when a measurement could not be taken, say the number is old rather than implying it is current. WHEN CHANGES WERE APPLIED the response carries `verify_urls` and `verify_note`: the safety checks read HTML in PHP and cannot execute JavaScript, so a page can pass every one of them and still be broken in a browser. Before reporting success, OPEN each URL in `verify_urls` if you have any way to load a page and confirm it renders with no console errors; if you cannot, tell the user those URLs need checking and what a problem would look like. `verified: true` means the HTML checks passed — it is not a statement that the site works.',
				'inputSchema' => self::object_schema(
					array(
						'aggressiveness' => array(
							'type'        => 'string',
							'enum'        => array( 'safe', 'standard', 'aggressive' ),
							'description' => 'How far to go. Defaults to standard.',
						),
						'dry_run'        => array(
							'type'        => 'boolean',
							'description' => 'Return the plan without changing anything.',
						),
						'measure_score'  => array(
							'type'        => 'string',
							'enum'        => array( 'auto', 'never', 'always' ),
							'description' => 'Whether to take a fresh PageSpeed measurement. auto (default) measures when the stored score is stale and after changes land; never reuses the stored score; always measures even for a dry run. Measurements are rate-limited, so a run inside the cooldown returns the stored score with its age rather than a new one.',
						),
						'target_score'   => array(
							'type'        => 'integer',
							'minimum'     => 1,
							'maximum'     => 100,
							'description' => 'Repeat the optimize cycle toward this score instead of running a single pass. Each round costs a real PageSpeed measurement and up to two minutes, so pass it only when the user asked for a specific number. Requires the iterative tuner; without it the run is a single pass and the report says so in `stopped_because`. The run also stops early when further rounds stop helping — either way, read `stopped_because` and relay it rather than retrying.',
						),
						'max_rounds'     => array(
							'type'        => 'integer',
							'minimum'     => 1,
							'description' => 'Ceiling on rounds when target_score is set. Clamped to what the tuner allows.',
						),
					),
					array()
				),
				'write'       => true,
				'handler'     => array( self::class, 'optimize_site' ),
			),
			'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 and report what was actually cleared, what was skipped and why. "type" selects what to purge: all, page, assets, object, rest, cloudflare, or cdn. 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' => 'Preview database bloat — post revisions, auto-drafts, trashed posts, spam comments, expired transients, orphaned meta — with a count per category. Deletes NOTHING. Also returns the confirm_token that clean_database requires, so this is always the first step before any deletion.',
				'inputSchema' => self::object_schema( array(), array() ),
				'write'       => false,
				'handler'     => array( self::class, 'scan_database' ),
			),
			'clean_database'   => array(
				'description' => 'PERMANENTLY DELETE database bloat — post revisions, trashed posts, spam comments and the other categories enabled in the Database module settings. This is not a cache purge: it destroys real content and CANNOT be undone. Requires a confirm_token from scan_database, which shows the caller exactly what would be removed; the call is refused without one.',
				'inputSchema' => self::object_schema(
					array(
						'confirm_token' => array(
							'type'        => 'string',
							'description' => 'The token returned by scan_database. Required — it proves the caller has seen what will be deleted. Expires after 5 minutes and is invalidated if the database changes.',
						),
					),
					array( 'confirm_token' )
				),
				'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_score'        => array(
				'description' => 'Run an external performance audit (PageSpeed Insights, or GTmetrix when configured) against this site. Running it counts as the opt-in for external scores. With the site\'s own API key the score plus Core Web Vitals come back in the response; a keyless PageSpeed audit on a Hub-connected site is queued instead — the response says so, and the result lands in the history a minute or two later (poll with run_command "score status", or read get_score_history). Spends the site\'s own configured API quota when a key is set.',
				'inputSchema' => self::object_schema(
					array(
						'target'   => array(
							'type'        => 'string',
							'description' => 'URL to audit. Defaults to the configured URL, then the home page.',
						),
						'strategy' => array(
							'type'        => 'string',
							'enum'        => array( 'mobile', 'desktop' ),
							'description' => 'mobile (default) or desktop. PageSpeed Insights only.',
						),
						'provider' => array(
							'type'        => 'string',
							'description' => 'Audit provider, when the site has more than one configured.',
						),
						'force'    => array(
							'type'        => 'boolean',
							'description' => 'Re-run even when a recent cached result exists. Use after a change you want measured immediately.',
						),
					),
					array()
				),
				// Classified `write`, deliberately. #147 asked whether a
				// read-only grant should be able to call this, since a run
				// changes no site CONFIGURATION. But it spends the site's own
				// metered PSI/GTmetrix quota and persists a Score_Store row,
				// and "read-only" should mean a call cannot cost the owner
				// anything. The gap this closes is that Free had NO typed
				// trigger at all: run_pagespeed is conditional on the Pro-only
				// `xspeed psi` command and silently drops off tools/list here,
				// leaving only run_command — also write, and a gateway to the
				// entire CLI surface. A write-scoped Hub connection now gets a
				// first-class trigger instead of the blunt instrument.
				'write'       => true,
				'handler'     => array( self::class, 'run_score' ),
			),
			'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. Running it counts as the opt-in for external scores; a keyless PageSpeed audit routes through xSpeed Hub when the site is connected.',
				'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.',
						),
						'provider' => array(
							'type'        => 'string',
							'description' => 'Audit provider, when the site has more than one configured.',
						),
					),
					array()
				),
				// `write`, matching run_score — the two dispatch to the same
				// `xspeed psi` and cost the owner the same metered quota, so
				// classifying them oppositely let a read-only grant make a real
				// outbound audit through this one while the other refused it.
				// Aligned toward write rather than read: generate_critical_css
				// sets the precedent that spending an external quota is a write
				// even when no site configuration changes. (QA B2 on #162)
				'write'       => true,
				'handler'     => array( self::class, 'run_pagespeed' ),
			),
			'generate_critical_css' => array(
				'description' => 'Generate above-the-fold Critical CSS for one page (Pro). Calls the external generator and stores the result for that page\'s template. Defaults to the site home page; pass url to build it for another page or template.',
				'inputSchema' => self::object_schema(
					array(
						'url' => array(
							'type'        => 'string',
							'description' => 'Page on this site to generate Critical CSS for, as a full URL or a path such as /pricing/. Defaults to the site home page.',
						),
					),
					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. Failed runs are included: ok is false and error says why, with score null. Never average or trend a run whose ok is false — it measured nothing. Read-only — returns what this site already measured and never starts a new audit. Use run_score 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.',
						),
						'takeover' => array(
							'type'        => 'boolean',
							'description' => 'With enabled=true: switch from the plugin that owns object-cache.php. Without it, enabling refuses while another plugin owns the file.',
						),
						'restore'  => array(
							'type'        => 'boolean',
							'description' => 'With enabled=false: put back the plugin xSpeed switched from.',
						),
					),
					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("psi", {}, {"url":"https://site.com","strategy":"mobile"}). Permanently destructive commands additionally require a confirm_token and are refused without one — this gateway is not a way around that confirmation. "database clean" takes its token from scan_database, which previews exactly what would be deleted; every other destructive command is refused once and the refusal carries a token, so repeating the same call with it confirms the action.',
				'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 }.',
						),
						'confirm_token' => array(
							'type'        => 'string',
							'description' => 'Required ONLY for permanently destructive commands. For "database clean" obtain it from scan_database, which previews exactly what would be deleted; for any other destructive command, make the call once without this argument and the refusal returns the token to repeat it with. Without it those commands are refused.',
						),
					),
					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',
			'purge_cache'       => 'xspeed purge',
		);

		$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;
			}
		}

		/**
		 * Add private tools owned by an installed extension.
		 *
		 * Extensions receive an EMPTY map and may only add new names. Core tool
		 * definitions cannot be replaced through this seam. A `hidden` tool is
		 * callable through the authenticated per-site proxy but omitted from
		 * tools/list. This is for broker-run workflows whose public tool must not
		 * be advertised until the broker-side worker exists.
		 *
		 * `hidden` is not a permission: the proxy takes the same pairing token
		 * the public channel does. It withholds a tool from discovery and from
		 * an OAuth grant, nothing more. See the gate in invoke().
		 *
		 * `hidden` must be a real bool when present. It decides whether a tool
		 * is reachable from the public channel at all, so a truthy string is
		 * the kind of near-miss that should be rejected rather than guessed at.
		 * Specs that fail any check here are skipped, not repaired.
		 *
		 * @param array<string,array<string,mixed>> $tools Extension tool specs.
		 */
		$extensions = apply_filters( 'xspeed_mcp_extension_tools', array() );
		if ( is_array( $extensions ) ) {
			foreach ( $extensions as $name => $spec ) {
				if (
					! is_string( $name )
					|| 1 !== preg_match( '/^[a-z][a-z0-9_]{0,63}$/', $name )
					|| isset( $catalog[ $name ] )
					|| ! is_array( $spec )
					|| ! isset( $spec['description'], $spec['inputSchema'], $spec['handler'], $spec['write'] )
					|| ! is_string( $spec['description'] )
					|| ! is_array( $spec['inputSchema'] )
					|| ! is_callable( $spec['handler'] )
					|| ! is_bool( $spec['write'] )
					|| ( isset( $spec['hidden'] ) && ! is_bool( $spec['hidden'] ) )
				) {
					continue;
				}
				$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;
				}
			}

			// Prefer the AI-facing hint. `shortdesc` is CLI help — written for
			// someone who already chose the command — so it says what the
			// output looks like, never when to reach for it. That is exactly
			// the question a model is answering when it reads tools/list, and
			// it is why 36 of these descriptions open with "Show". A module
			// that has not been given a hint yet keeps its shortdesc, so this
			// improves incrementally instead of needing all 40 at once. (#184)
			$description = '' !== ( $spec['ai_hint'] ?? '' )
				? $spec['ai_hint']
				: ( '' !== $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 ) {
			if ( ! empty( $spec['hidden'] ) ) {
				continue;
			}
			$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;
		}

		// Hidden tools are private broker stages, not undiscoverable public MCP
		// tools. Omitting one from tools/list is only presentation, so this
		// closes the JSON-RPC channel to them as well, returning the same 404 a
		// nonexistent name returns. Both entry paths set the channel before
		// every invoke, so a previous broker call cannot widen a later one.
		//
		// What this is NOT: a permission boundary. The broker REST route
		// authenticates with the SAME pairing token as the JSON-RPC route
		// (Mcp_Auth::permission and Mcp_Server::authorize both compare against
		// Mcp_Pairing::site_token()), so anyone holding that token — every
		// client the dashboard's connection recipes are written for — can call
		// a hidden tool by name on the broker route, and tell it from a
		// nonexistent one by the status. `hidden` keeps a tool off the
		// advertised surface and out of an OAuth grant's reach; it does not
		// make it safe for a pairing-token holder to run. Anything gated only
		// by `hidden` must be something that holder may already do.
		if ( ! empty( $catalog[ $name ]['hidden'] ) && 'broker' !== self::$channel ) {
			$error = new \WP_Error(
				'xspeed_mcp_unknown_tool',
				sprintf(
					/* translators: %s: tool name. */
					__( 'Unknown tool: %s', 'xspeed' ),
					$name
				),
				array( 'status' => 404 )
			);
			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 )
			);
		}

		/*
		 * Scan-before-clean, enforced at the dispatcher rather than in one
		 * handler.
		 *
		 * The guard used to live inside clean_database(). That protected a
		 * TOOL NAME, not the action: run_command("db", ["clean"]) reaches the
		 * same Cli_Bridge::run('db', ['clean']) with no token, no preview and
		 * no warning, and list_commands advertises the route to the assistant
		 * in plainer words ("Scan or clean WordPress bloat") than the tool
		 * that just refused it. Measured on a live site, that second door
		 * permanently destroyed 3,007 rows in a single call and reported
		 * success.
		 *
		 * Every tool passes through invoke(), so a confirmation checked here
		 * covers each door at once — including any future tool that wraps the
		 * same command. (#184)
		 */
		$destructive = self::destructive_action( $name, $args );
		if ( ! empty( $destructive ) ) {
			$confirmed = self::verify_confirm_token( $args, $name, $destructive );
			if ( is_wp_error( $confirmed ) ) {
				// The refusal message can carry a freshly minted token (see
				// confirm_required()); the log gets the redacted twin so a
				// live token is never persisted in wp_options twice over.
				Mcp_Activity_Log::record( $name, $args, false, self::loggable_error( $confirmed ), 'write', self::$channel );
				return $confirmed;
			}
		}

		/*
		 * The token authorised the call; it is not an argument any handler
		 * takes. A generated command tool forwards every property it does not
		 * recognise as a positional straight to Cli_Bridge::run() as a CLI
		 * option, so leaving it in place would turn a correctly confirmed
		 * `xspeed_cfe` call into `--confirm_token=…` and an unknown-option
		 * failure: the gate satisfied and the action still unreachable, which
		 * is the same broken feature by a later route. Dropped for every tool,
		 * not just the gated ones, and dropped before the audit record so a
		 * live token is never written into the activity log.
		 */
		unset( $args['confirm_token'] );

		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.
	 *
	 * `site_icon` is the Site Icon set under Appearance, or '' when none is
	 * set. The Hub shows it beside the site's name. Asking the site for
	 * /favicon.ico instead failed on nginx hosts, which answer .ico
	 * requests as static files and never reach WordPress.
	 *
	 * @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(),
			'site_icon'     => (string) get_site_icon_url( 64 ),
		);
	}

	/**
	 * Facts about this site and install, stated explicitly.
	 *
	 * The Hub's fleet dashboard needed two things no tool reported directly.
	 * It had been INFERRING Pro's presence from `list_modules` — "any entry
	 * with tier: pro" — which works only because the registry returns just
	 * available modules. That is an inference riding an implementation
	 * detail, and it breaks the day a Pro install registers zero Pro modules.
	 *
	 * `pro_active` is "the Pro plugin is loaded and API-compatible";
	 * `licensed` is a separate question, since Pro can be active but
	 * unlicensed (its modules then boot but their settings are locked). Both
	 * are reported so a consumer never has to guess which one it wanted.
	 * (#146)
	 *
	 * @param array $args Unused.
	 * @return array
	 */
	/**
	 * Is Pro licensed right now?
	 *
	 * Resolved through the `xspeed_module_descriptor` filter — the one Pro
	 * actually registers — by running a minimal Pro descriptor through it and
	 * reading back the `locked` flag Pro sets when the licence is inactive.
	 *
	 * `license` is deliberately not used as the probe slug: Pro exempts that
	 * module from locking so an expired site can still reach the screen where
	 * a new key is entered, so it would always come back unlocked.
	 */
	private static function pro_licensed(): bool {
		$probe = apply_filters(
			'xspeed_module_descriptor',
			array(
				'slug' => '__license_probe__',
				'tier' => 'pro',
			),
			null
		);

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

	public static function get_site_info( array $args ) {
		unset( $args );

		$pro_active = Tier_Registry::pro_active();

		return array(
			'pro_active'     => $pro_active,
			'pro_version'    => defined( 'XSPEED_PRO_VERSION' ) ? (string) constant( 'XSPEED_PRO_VERSION' ) : null,
			// Distinct from pro_active: Pro can be installed and running
			// while its license is expired or absent.
			//
			// NOT `apply_filters( 'xspeed_pro_licensed', true )`. That hook is
			// only ever APPLIED by Pro as an override point — no released
			// version registers it — so with nothing listening the `true`
			// default stood and this reported `licensed: true` on a fully
			// revoked licence: the exact misreport the tool exists to
			// eliminate. (QA blocker on #158)
			//
			// Ask the question the dashboard asks instead. Pro DOES register
			// `xspeed_module_descriptor` and stamps `locked => 'license'` on
			// every Pro entry when the licence is inactive, so reading that
			// back is a real signal, and it cannot drift from what the panel
			// shows because it IS what the panel shows.
			'licensed'       => $pro_active ? self::pro_licensed() : false,
			'plugin_version' => defined( 'XSPEED_VERSION' ) ? (string) constant( 'XSPEED_VERSION' ) : null,
			'wp_version'     => get_bloginfo( 'version' ),
			'php_version'    => PHP_VERSION,
			'server'         => Server::type(),
			'multisite'      => is_multisite(),
			'addons'         => self::addon_licences(),
		);
	}

	/** Most add-on entries `get_site_info` reports. */
	private const MAX_ADDONS = 20;

	/** Longest licence status kept. The library's statuses are short slugs. */
	private const MAX_ADDON_STATUS_LENGTH = 40;

	/**
	 * The licence state of each separately licensed add-on, keyed by slug.
	 *
	 * Free cannot know which add-ons exist, so it asks. An add-on answers from
	 * the licence state it has stored. Callers poll this tool, and a
	 * licence-server round trip here would make every call wait on it.
	 *
	 * Always an object, `{}` when nothing answers. An empty PHP array encodes
	 * as `[]`, and a consumer reading `addons.some_slug` should not have to
	 * handle a list as well.
	 *
	 * @return object Slug => `{licensed: bool, status: ?string}`.
	 */
	private static function addon_licences(): object {
		/**
		 * Filter: xspeed_site_info_addons
		 *
		 * Licence state for add-ons sold separately, reported by the
		 * `get_site_info` MCP tool. Seeded empty; add your own entry and
		 * return the array:
		 *
		 *     $addons['my_addon'] = array( 'licensed' => true, 'status' => 'valid' );
		 *
		 * `licensed` is whether the add-on may run. `status` is the licence
		 * status as stored, or null when no key is stored. Read stored state
		 * only, never the licence server. Keys go through sanitize_key(),
		 * statuses are trimmed to short slugs, and any other field is dropped.
		 *
		 * @param array $addons Entries gathered so far, keyed by slug.
		 */
		try {
			$raw = apply_filters( 'xspeed_site_info_addons', array() );
		} catch ( \Throwable $e ) {
			// A broken add-on costs the add-on entries, not the whole tool.
			// The other facts are still true and the caller still needs them.
			// Pop our hook off the stack the throw left it on, for the reason
			// Pro_Audit::contributed() gives.
			if ( isset( $GLOBALS['wp_current_filter'] )
				&& is_array( $GLOBALS['wp_current_filter'] )
				&& end( $GLOBALS['wp_current_filter'] ) === 'xspeed_site_info_addons' ) {
				array_pop( $GLOBALS['wp_current_filter'] );
			}
			return (object) array();
		}

		$out = array();
		foreach ( is_array( $raw ) ? $raw : array() as $slug => $entry ) {
			if ( count( $out ) >= self::MAX_ADDONS ) {
				break;
			}
			if ( ! is_string( $slug ) || ! is_array( $entry ) ) {
				continue;
			}
			$slug = sanitize_key( $slug );
			if ( '' === $slug || isset( $out[ $slug ] ) ) {
				continue;
			}

			$status = $entry['status'] ?? null;
			$status = is_string( $status )
				? substr( sanitize_key( $status ), 0, self::MAX_ADDON_STATUS_LENGTH )
				: '';

			$out[ $slug ] = array(
				'licensed' => (bool) ( $entry['licensed'] ?? false ),
				// An empty status says nothing a null does not, and two ways
				// of saying "none" is one more case for every consumer.
				'status'   => '' === $status ? null : $status,
			);
		}

		return (object) $out;
	}

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

	/**
	 * Run the optimization autopilot.
	 *
	 * A thin wrapper: everything — the plan, the verification, the revert —
	 * lives in Optimize_Runner, so the CLI and this tool cannot drift into
	 * making different decisions about the same site.
	 *
	 * @param array<string,mixed> $args Tool arguments.
	 * @return array<string,mixed>|\WP_Error
	 */
	public static function optimize_site( array $args = array() ) {
		$run = array(
			'aggressiveness' => (string) ( $args['aggressiveness'] ?? 'standard' ),
			'dry_run'        => (bool) ( $args['dry_run'] ?? false ),
			'measure_score'  => (string) ( $args['measure_score'] ?? 'auto' ),
		);

		// Tuning arguments are forwarded ONLY when present, and are not
		// understood by Free — a listener on `xspeed_optimize_report` reads
		// them from that filter's `$context`. Passing them through rather
		// than naming them in the array above is deliberate: this handler
		// builds an explicit whitelist, so an argument it does not list is
		// silently dropped. A caller asking to reach a score would have got a
		// single pass and a success response — wrong behaviour with no error,
		// which is the expensive kind to diagnose.
		foreach ( array( 'target_score', 'max_rounds' ) as $key ) {
			if ( isset( $args[ $key ] ) && is_numeric( $args[ $key ] ) ) {
				$run[ $key ] = (int) $args[ $key ];
			}
		}

		return \XSpeed\Optimize_Runner::run( $run );
	}

	/**
	 * 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.
		$cause = __( 'AI assistant', 'xspeed' );

		/*
		 * `page`, `assets` and `rest` are fine-grained slices of the local
		 * sweep with no target of their own, and they predate this tool's
		 * per-store report — an assistant asking for `page` means the HTML,
		 * not the HTML plus the minified bundles plus every purge listener.
		 * They stay on purge_type() so their meaning does not change under
		 * callers already relying on it.
		 *
		 * Everything else routes through the runner — the same core function
		 * the CLI and the REST callback use — so an assistant told "cache
		 * cleared" is reading the same per-store verdict a human would get,
		 * including a Cloudflare zone that refused the purge.
		 */
		if ( in_array( $type, array( 'page', 'assets', 'rest' ), true ) ) {
			return array(
				'purged' => $type,
				'count'  => Cache::purge_type( $type, $cause ),
				'ok'     => true,
				'stats'  => Cache::get_stats(),
			);
		}

		$report = \XSpeed\Purge_Runner::run( array( $type ), $cause );
		$count  = 0;
		foreach ( $report['types'] as $row ) {
			$count += (int) $row['entries'];
		}

		return array(
			'purged' => $type,
			'count'  => $count,
			'ok'     => $report['ok'],
			'report' => $report['types'],
			'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.
		//
		// From the RESULT, not from $enabled: toggle() refuses to enable when
		// another caching plugin owns the drop-in, and writing the requested
		// value regardless left the site reporting a cache it had not
		// installed — over MCP, with no human reading the response.

		return array(
			'cache_enabled'  => $install['enabled'],
			'blocked'        => ! empty( $install['blocked'] ),
			'blocked_reason' => $install['blocked_reason'] ?? null,
			'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 )
			);
		}
		/**
		 * Filter the get_settings MCP payload for one module.
		 *
		 * Lets the module that owns the settings attach state the stored
		 * values alone cannot express — a toggle that is on but resolves to
		 * no effect on this host (Brotli without ngx_brotli), a configured
		 * generator that has never succeeded. Free never names Pro classes,
		 * so this seam is how a Pro module reaches the response an agent
		 * reads.
		 *
		 * @param array<string,mixed> $payload The response: module + settings.
		 * @param string              $module  Module slug.
		 * @param string              $action  'get' here; 'update' on writes.
		 */
		return apply_filters(
			'xspeed_mcp_settings_payload',
			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 ),
			),
			$module,
			'get'
		);
	}

	/**
	 * 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,
					)
				);
			}
		}
		// The Pro licence WRITE gate. `settings_module_reachable()` above already
		// hides locked Pro modules, but that is a VISIBILITY check answered by
		// the `xspeed_module_descriptor` filter — a different question from "may
		// this be written", and one that drifts the moment Pro changes how it
		// flags `locked`. Ask the write gate itself, the same one REST consults
		// via Module::update_settings(), so the two can't disagree.
		//
		// This is not theoretical: with the descriptor's `locked` flag removed,
		// this handler wrote `enabled: false -> true` to a module whose
		// is_license_locked() was true, because it persists through
		// Settings_Manager::update() and never reaches Module::update_settings().
		// (#185)
		$module_object = \XSpeed\Module_Registry::get( $module );
		if ( $module_object && $module_object->is_license_locked() ) {
			// Match the REST path's audit trail — a refused write is a security
			// event and must be visible in the activity log wherever it came
			// from. Module::license_write_refusal() records the same type.
			\XSpeed\Activity_Log::record(
				'license_write_refused',
				sprintf(
					/* translators: %s: module slug. */
					__( 'Refused an MCP settings write to the Pro module "%s" — no valid license.', 'xspeed' ),
					$module
				),
				\XSpeed\Activity_Log::WARN
			);

			return new \WP_Error(
				'xspeed_license_required',
				sprintf(
					/* translators: %s: module slug. */
					__( '"%s" is a Pro module and this site has no active license, so the write was refused. Nothing was changed.', 'xspeed' ),
					$module
				),
				array(
					'status' => 403,
					'module' => $module,
				)
			);
		}

		// An agent cannot tell a silent no-op from a real write, so refuse
		// instead of returning a success payload. update() walks the schema:
		// an out-of-schema key is never written and never mentioned, and an
		// in-schema key with a rejected value quietly keeps the stored one.
		// The realistic case is `cache_enabled` on the `cache` module — the
		// most natural way to ask for caching, and a complete no-op. (#206)
		$report = self::inspect_or_error( $module, $values );
		if ( is_wp_error( $report ) ) {
			return $report;
		}

		/**
		 * Filter the update_settings MCP payload for one module.
		 *
		 * The write path's twin of the get filter above — this is where a
		 * module can say "stored, but inert on this host" in the same
		 * response that reports the write, instead of returning a plain
		 * success an agent relays as "enabled". Documented in
		 * docs/guides/hooks-and-filters.md.
		 *
		 * @param array<string,mixed> $payload The response: module + settings.
		 * @param string              $module  Module slug.
		 * @param string              $action  'update' here; 'get' on reads.
		 */
		return apply_filters(
			'xspeed_mcp_settings_payload',
			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 ),
			),
			$module,
			'update'
		);
	}

	/**
	 * Refuse a settings payload carrying keys that would be silently dropped.
	 *
	 * @param string              $module Module slug.
	 * @param array<string,mixed> $values Proposed values.
	 * @return true|\WP_Error True when every key would be applied.
	 */
	private static function inspect_or_error( string $module, array $values ) {
		$report = Settings_Manager::inspect_input( $module, $values );
		if ( empty( $report['unknown'] ) && empty( $report['invalid'] ) && empty( $report['locked'] ) ) {
			return true;
		}

		$parts = array();
		// A field pinned by a wp-config.php constant cannot be written. Say so
		// rather than returning a success the agent relays as "changed" over a
		// write that update() would silently drop. (#398)
		foreach ( $report['locked'] as $key ) {
			$spec     = ( Module_Registry::get( $module ) ? Module_Registry::get( $module )->settings_schema()[ $key ] ?? array() : array() );
			$constant = Settings_Manager::effective_constant( $module, $key, is_array( $spec ) ? $spec : array() );
			$parts[]  = sprintf(
				/* translators: 1: setting key, 2: wp-config.php constant name. */
				__( '"%1$s" is defined in wp-config.php as %2$s and cannot be changed here', 'xspeed' ),
				$key,
				(string) $constant
			);
		}
		foreach ( $report['unknown'] as $key ) {
			$detail = sprintf(
				/* translators: 1: setting key, 2: module slug. */
				__( '"%1$s" is not a setting of module "%2$s"', 'xspeed' ),
				$key,
				$module
			);
			$hint = Settings_Manager::hint_for_unknown_key( $key );
			if ( '' !== $hint ) {
				$detail .= ' — ' . $hint;
			} else {
				$near = Settings_Manager::did_you_mean( $module, $key );
				if ( ! empty( $near ) ) {
					$detail .= sprintf(
						/* translators: %s: comma-separated setting names. */
						__( ' — did you mean: %s?', 'xspeed' ),
						implode( ', ', $near )
					);
				}
			}
			$parts[] = $detail;
		}
		foreach ( $report['invalid'] as $key ) {
			$parts[] = sprintf(
				/* translators: %s: setting key. */
				__( '"%s" was rejected by the schema (wrong type, or outside the allowed range/options)', 'xspeed' ),
				$key
			);
		}

		return new \WP_Error(
			'xspeed_settings_refused',
			sprintf(
				/* translators: 1: module slug, 2: reasons. */
				__( 'Refused to update %1$s — nothing was written. %2$s', 'xspeed' ),
				$module,
				implode( '; ', $parts )
			),
			array(
				'status'          => 400,
				'refused_unknown' => $report['unknown'],
				'refused_invalid' => $report['invalid'],
				'refused_locked'  => $report['locked'],
				'would_apply'     => $report['applied'],
			)
		);
	}

	/**
	 * 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 );
		$result = Cli_Bridge::run( 'db', array( 'scan' ) );
		if ( is_wp_error( $result ) || empty( $result['ok'] ) ) {
			return $result;
		}

		/*
		 * Mint the token clean_database will demand, and state what it covers.
		 *
		 * The scan is the only place the caller can see what is about to be
		 * destroyed, so it is the only honest place to authorise the delete.
		 * The token is bound to the CATEGORIES ENABLED and the COUNTS FOUND at
		 * this moment: if either moves before the delete lands, the token no
		 * longer describes reality and clean_database refuses. That closes the
		 * window where a scan is shown to a human, something changes, and the
		 * delete removes more than was agreed to. (#184)
		 */
		$result['confirm_token'] = self::mint_confirm_token( self::clean_fingerprint() );
		$result['confirm_note']  = __( 'This preview deletes nothing. To delete what is listed, call clean_database with this confirm_token. It expires in 5 minutes and stops working if the database changes.', 'xspeed' );

		return $result;
	}

	/** Categories currently enabled for deletion, with what a scan found in each. */
	private static function clean_scope(): array {
		$enabled = array_keys( array_filter( Settings_Manager::get( 'database' ), static fn( $v ) => true === $v ) );
		sort( $enabled );

		$counts = array();
		foreach ( Database_Cleaner::scan() as $key => $row ) {
			$counts[ $key ] = is_array( $row ) ? (int) ( $row['count'] ?? 0 ) : (int) $row;
		}
		ksort( $counts );

		return array(
			'enabled' => $enabled,
			'counts'  => $counts,
		);
	}

	/**
	 * Actions that permanently destroy content and therefore require a
	 * confirm_token, keyed by canonical command name.
	 *
	 * Keyed by ACTION, not by tool name, because the same action is
	 * reachable through several tools (the typed clean_database, the
	 * run_command gateway, and any future wrapper).
	 *
	 * @return array<string, string[]>
	 */
	private static function destructive_actions(): array {
		/**
		 * Filter the command actions that require an explicit confirmation.
		 *
		 * @since 1.1.6
		 * @param array<string, string[]> $actions Action names keyed by command.
		 */
		return (array) apply_filters(
			'xspeed_mcp_destructive_actions',
			array( 'xspeed db' => array( 'clean' ) )
		);
	}

	/**
	 * Name the destructive action a call would run, or an empty array if it
	 * is harmless.
	 *
	 * @param string $name Tool name.
	 * @param array  $args Decoded tool arguments.
	 * @return array{name?:string,action?:string} The classified pair, or array() when not destructive.
	 */
	private static function destructive_action( string $name, array $args ): array {
		// The gateway carries the real command in its arguments; a typed tool
		// is identified by the command it is mapped to.
		if ( 'run_command' === $name ) {
			$command = isset( $args['command'] ) ? (string) $args['command'] : '';
			if ( '' === $command ) {
				return array();
			}
			$positional = isset( $args['args'] ) && is_array( $args['args'] ) ? $args['args'] : array();
			return self::destructive_hit( Cli_Bridge::classify( $command, $positional ) );
		}

		if ( 'clean_database' === $name ) {
			return self::destructive_hit( Cli_Bridge::classify( 'db', array( 'clean' ) ) );
		}

		/*
		 * The third door: the tool generated for every registered CLI command.
		 *
		 * run_command and clean_database were the two names this guard knew,
		 * so a command whose destructive action the filter names was still
		 * reachable without a confirm_token by calling its own generated tool
		 * — `xspeed_cfe` with action `remove` ran what
		 * run_command("cfe", ["remove"]) would have refused. The generated
		 * tool takes its action as the `action` property, so resolve the name
		 * back to a command and classify that action exactly as the gateway
		 * classifies its own.
		 */
		$action = isset( $args['action'] ) ? (string) $args['action'] : '';
		if ( '' === $action ) {
			return array();
		}

		$candidates = self::cli_commands_by_tool_name()[ $name ] ?? array();
		if ( empty( $candidates ) ) {
			/*
			 * The map has no entry for this tool name. Two ways to get here
			 * and neither may answer "harmless":
			 *
			 *   - the registry is unavailable (an unbooted module, a call
			 *     arriving before modules register, a bare unit-test context);
			 *   - the registry is POPULATED but does not contain the command
			 *     this tool name came from — Cli_Bridge::commands() memoises
			 *     in a function-local static with no reset, so a module that
			 *     registers its commands after the first call is invisible to
			 *     the map while its generated tool is still dispatchable.
			 *
			 * This condition used to also require `array() === commands()`,
			 * which made the second case fall through with no candidates at
			 * all and return "not destructive" — the gate opening precisely
			 * where it was least able to see. Fall back on the naive inverse
			 * of cli_tool_name() in both cases. It is the mapping that can be
			 * wrong (underscores in a command name), but wrong here means
			 * asking for a confirm_token that was not strictly needed, never
			 * skipping one that was.
			 */
			$candidates = array( str_replace( '_', ' ', $name ) );
		}

		foreach ( $candidates as $command ) {
			$hit = self::destructive_hit( Cli_Bridge::classify( $command, array( $action ) ) );
			if ( ! empty( $hit ) ) {
				return $hit;
			}
		}

		return array();
	}

	/**
	 * Render a classified pair as the canonical "<command> <action>".
	 *
	 * @param array{name?:string,action?:string} $action Pair from destructive_action().
	 * @return string Canonical name, or '' for the empty (harmless) pair.
	 */
	private static function canonical_action( array $action ): string {
		if ( empty( $action['name'] ) ) {
			return '';
		}
		return trim( $action['name'] . ' ' . ( $action['action'] ?? '' ) );
	}

	/**
	 * Is a classified (command, action) pair one the filter calls destructive?
	 *
	 * @param array{name:string,action:string} $resolved Output of Cli_Bridge::classify().
	 * @return array{name?:string,action?:string} The pair, or array() when not destructive.
	 */
	private static function destructive_hit( array $resolved ): array {
		if ( '' === $resolved['name'] ) {
			return array();
		}

		$destructive = self::destructive_actions();
		if ( ! isset( $destructive[ $resolved['name'] ] ) ) {
			return array();
		}
		if ( ! in_array( $resolved['action'], (array) $destructive[ $resolved['name'] ], true ) ) {
			return array();
		}

		return array(
			'name'   => $resolved['name'],
			'action' => $resolved['action'],
		);
	}

	/**
	 * Which CLI command(s) a generated tool name could have come from.
	 *
	 * cli_tool_name() replaces spaces with underscores, which is not
	 * injective: a command named "xspeed foo bar" and one named
	 * "xspeed foo_bar" produce the same tool name, and splitting the tool
	 * name back on underscores cannot tell them apart. So the map is built
	 * forwards — cli_tool_name() applied to every registered command, the
	 * same function over the same input set that named the tools in the
	 * first place. No Free command carries an underscore today; a Pro or
	 * third-party module registering one must not quietly disarm this guard.
	 *
	 * A tool name claimed by two commands keeps both, and the caller treats
	 * the call as destructive if any of them is — the fail-closed direction,
	 * the same reasoning Cli_Bridge::classify() applies when the registry
	 * cannot resolve an input at all.
	 *
	 * @return array<string,string[]> Tool name => the commands that produce it.
	 */
	private static function cli_commands_by_tool_name(): array {
		$map = array();
		foreach ( array_keys( Cli_Bridge::commands() ) as $command ) {
			$tool_name = self::cli_tool_name( (string) $command );
			if ( '' === $tool_name ) {
				continue;
			}
			$map[ $tool_name ][] = (string) $command;
		}
		return $map;
	}

	/**
	 * Marker: this build gates destructive command actions on a confirm_token
	 * whichever tool they are reached through, generated tools included.
	 *
	 * Add-ons that register a destructive CLI action need to know whether the
	 * host plugin will demand the second factor for them. Where this method
	 * is absent they have to refuse the action themselves when it arrives
	 * from anywhere but the dashboard or real wp-cli; where it answers true
	 * they can let the confirmation gate do the work. Keep it — an add-on
	 * keys its refusal on the absence, so removing it is a behaviour change
	 * in another plugin.
	 */
	public static function supports_command_confirmation(): bool {
		return true;
	}

	/**
	 * The one destructive action whose token comes from a preview tool.
	 *
	 * `scan_database` is the only surface that shows what a delete would
	 * remove, so its token is sealed to that preview and nothing else may
	 * mint one — including `xspeed_db` with `action: clean`, which is just
	 * another door onto the same rows.
	 */
	private const PREVIEW_CONFIRMED_ACTION = 'xspeed db clean';

	/**
	 * Verify (and consume) the confirm_token for a destructive action.
	 *
	 * @param array  $args   Decoded tool arguments.
	 * @param string $tool   Tool name the call arrived on.
	 * @param array  $action Classified pair from destructive_action().
	 * @return true|\WP_Error
	 */
	private static function verify_confirm_token( array $args, string $tool, array $action ) {
		$token = isset( $args['confirm_token'] ) ? (string) $args['confirm_token'] : '';
		if ( '' === $token ) {
			return self::confirm_required( $tool, $action );
		}

		// Computed only now: for the database clean the fingerprint is a full
		// bloat scan, and a call refused for having no token at all should not
		// pay for one.
		$expected = self::confirm_fingerprint( $action );

		// Single use: consumed whether or not the action goes ahead, so one
		// approval can never authorise a second, different call.
		$sealed = self::consume_confirm_token( $token );
		if ( '' === $sealed ) {
			return new \WP_Error(
				'xspeed_mcp_confirm_invalid',
				__( 'That confirm_token is unknown or has expired (they last 5 minutes). Ask for a fresh one by making the same call without a confirm_token.', 'xspeed' ),
				array( 'status' => 400 )
			);
		}

		if ( ! hash_equals( $sealed, $expected ) ) {
			return new \WP_Error(
				'xspeed_mcp_confirm_stale',
				self::PREVIEW_CONFIRMED_ACTION === self::canonical_action( $action )
					? __( 'The database changed since that scan, so the preview no longer describes what would be deleted. Run scan_database again and confirm against the new result.', 'xspeed' )
					: __( 'That confirm_token was issued for a different action, so it does not authorise this one. Repeat this exact call without a confirm_token to get one that does.', 'xspeed' ),
				array( 'status' => 409 )
			);
		}

		return true;
	}

	/**
	 * Refuse a destructive call that arrived without a confirm_token.
	 *
	 * Two shapes, because the two kinds of destructive action differ in where
	 * a token can honestly come from.
	 *
	 * `xspeed db clean` has a preview — `scan_database` — and the token is
	 * sealed to what that preview showed. Refuse and point at it; minting one
	 * here would authorise a delete nobody had been shown.
	 *
	 * Every other destructive action has no preview, and until now borrowed
	 * the database scope: the caller was told to "call scan_database first"
	 * for a token sealed to a fingerprint of row counts, which
	 * verify_confirm_token() would then reject as not matching. On an install
	 * where `xspeed db` is not registered there was no minting path at all,
	 * so the gate could not be passed by any sequence of calls. A guard that
	 * cannot be satisfied is a broken feature, and the pressure it creates is
	 * to delete the guard. (#184)
	 *
	 * So the refusal itself mints the token, sealed to this action. That is
	 * not authentication — the connection is already authenticated and holds
	 * write scope — it is a deliberate second round trip: the caller has to
	 * read a message naming what it is about to destroy and decide to send
	 * the call again. The token is single use, expires in five minutes, and
	 * confirms nothing but the action it names.
	 *
	 * The token travels in the MESSAGE, not just the error data:
	 * Mcp_Server::call_tool() renders a WP_Error as its message text alone
	 * and drops the data, so a token that lived only in the data would never
	 * reach the caller and the gate would stay unsatisfiable over MCP.
	 *
	 * @param string $tool   Tool name the call arrived on.
	 * @param array  $action Classified pair from destructive_action().
	 * @return \WP_Error
	 */
	private static function confirm_required( string $tool, array $action ): \WP_Error {
		$canonical = self::canonical_action( $action );

		if ( self::PREVIEW_CONFIRMED_ACTION === $canonical ) {
			return new \WP_Error(
				'xspeed_mcp_confirm_required',
				__( 'This permanently deletes content and cannot be undone. Call scan_database first to see exactly what would be removed, then pass the confirm_token it returns.', 'xspeed' ),
				array(
					'status'      => 400,
					'action'      => $canonical,
					'log_message' => sprintf( 'Refused %s: no confirm_token (scan_database mints it).', $canonical ),
				)
			);
		}

		$token = self::mint_confirm_token( self::confirm_fingerprint( $action ) );

		$repeat = 'run_command' === $tool
			? sprintf(
				/* translators: %s: the confirm_token to send back. */
				__( 'Call run_command again with the same command plus confirm_token: %s', 'xspeed' ),
				$token
			)
			: sprintf(
				/* translators: 1: tool name, 2: action value, 3: the confirm_token to send back. */
				__( 'Call %1$s again with action: %2$s plus confirm_token: %3$s', 'xspeed' ),
				$tool,
				(string) ( $action['action'] ?? '' ),
				$token
			);

		return new \WP_Error(
			'xspeed_mcp_confirm_required',
			sprintf(
				/* translators: 1: canonical "<command> <action>", 2: how to repeat the call. */
				__( '"%1$s" is destructive and cannot be undone, so it needs a second, deliberate call. %2$s — the token is single use and expires in 5 minutes.', 'xspeed' ),
				$canonical,
				$repeat
			),
			array(
				'status'        => 400,
				'action'        => $canonical,
				'confirm_token' => $token,
				'expires_in'    => self::CONFIRM_TOKEN_TTL,
				'log_message'   => sprintf( 'Refused %s: awaiting confirm_token.', $canonical ),
			)
		);
	}

	/**
	 * The activity-log text for a refusal.
	 *
	 * A refusal that mints a token puts that token in its message, because
	 * that is the only channel the caller can read it on. The activity log is
	 * a different audience and a durable one, so it takes the redacted twin
	 * the error carries alongside.
	 *
	 * @param \WP_Error $error Refusal to describe.
	 * @return string
	 */
	private static function loggable_error( \WP_Error $error ): string {
		$data = $error->get_error_data();
		if ( is_array( $data ) && ! empty( $data['log_message'] ) ) {
			return (string) $data['log_message'];
		}
		return $error->get_error_message();
	}

	/**
	 * The scope a confirm_token for this action is sealed to.
	 *
	 * `xspeed db clean` is sealed to the preview: the categories enabled and
	 * the counts found, so the token dies the moment the database stops
	 * matching what the operator was shown.
	 *
	 * Every other action is sealed to itself — the (command, action) pair —
	 * because there is nothing else to bind to and binding to the database
	 * would make the token both meaningless (it proves nothing about the
	 * action) and fragile (any unrelated write invalidates it). A token for
	 * `xspeed cfe pause` therefore does not confirm `xspeed cfe remove`.
	 *
	 * @param array $action Classified pair from destructive_action().
	 * @return string
	 */
	private static function confirm_fingerprint( array $action ): string {
		if ( self::PREVIEW_CONFIRMED_ACTION === self::canonical_action( $action ) ) {
			return self::clean_fingerprint();
		}

		return hash(
			'sha256',
			(string) wp_json_encode(
				array(
					(string) ( $action['name'] ?? '' ),
					(string) ( $action['action'] ?? '' ),
				)
			)
		);
	}

	/** Fingerprint of the scan scope, so a token cannot outlive what it described. */
	private static function clean_fingerprint(): string {
		return hash( 'sha256', (string) wp_json_encode( self::clean_scope() ) );
	}

	/** Lifetime of a confirm_token, from mint to refusal. */
	private const CONFIRM_TOKEN_TTL = 5 * MINUTE_IN_SECONDS;

	/**
	 * Storage key for a minted token (the token itself is never stored).
	 *
	 * The `xspeed_mcp_clean_` prefix predates tokens for actions other than
	 * the database clean. It is the on-disk format, and purge_expired_confirm_
	 * tokens() sweeps by exactly this prefix, so renaming it would strand
	 * every token minted by the running build.
	 */
	private static function confirm_token_key( string $token ): string {
		return 'xspeed_mcp_clean_' . hash( 'sha256', $token );
	}

	/*
	 * The token is held in an OPTION, not a transient.
	 *
	 * scan_database and clean_database are two separate HTTP requests, so the
	 * token has to survive between them. With an external object cache
	 * installed, set_transient() writes to that cache ONLY and never touches
	 * the options table — so on any site whose object cache is
	 * non-persistent, flushed between requests, or simply orphaned (a stale
	 * W3TC/Redis drop-in pointing at a dead backend), the token evaporates the
	 * moment it is minted.
	 *
	 * That does not fail safe. It makes the confirmation UNSATISFIABLE:
	 * clean_database can never be authorised by any sequence of calls, and the
	 * operator's only remaining route to the feature is the admin panel. A
	 * guard that cannot be passed is a broken feature, and the pressure it
	 * creates is to remove the guard. Reproduced on a stack running W3 Total
	 * Cache's object-cache drop-in: every freshly minted token was refused as
	 * "unknown or expired" on the very next request. (#184)
	 *
	 * Options are backed by the database, so the token persists whatever the
	 * object cache does. Expiry is carried in the stored value and checked on
	 * read, since options have no TTL of their own.
	 */

	/**
	 * Mint a single-use token sealed to a scope fingerprint.
	 *
	 * @param string $fingerprint Scope the token confirms — see confirm_fingerprint().
	 * @return string
	 */
	private static function mint_confirm_token( string $fingerprint ): string {
		$token = wp_generate_password( 32, false );

		// autoload=no: this is read once, by one request, minutes from now.
		add_option(
			self::confirm_token_key( $token ),
			wp_json_encode(
				array(
					'fingerprint' => $fingerprint,
					'expires'     => time() + self::CONFIRM_TOKEN_TTL,
				)
			),
			'',
			'no'
		);

		self::purge_expired_confirm_tokens();

		return $token;
	}

	/**
	 * Read a minted token's sealed fingerprint, or '' if unknown/expired.
	 *
	 * Consumes the record either way: a token is single use, so one approval
	 * can never authorise a second, different deletion.
	 */
	private static function consume_confirm_token( string $token ): string {
		global $wpdb;

		$key    = self::confirm_token_key( $token );
		$stored = get_option( $key );
		if ( ! is_string( $stored ) || '' === $stored ) {
			return '';
		}

		/*
		 * The claim is the DELETE, and nothing before it.
		 *
		 * This used to read the option, decide it existed, and then call
		 * delete_option() — check-then-act, with the whole verification
		 * sitting inside the gap. Twenty calls carrying one token, arriving
		 * together, all read the row before any of them removed it, and all
		 * twenty passed. QA measured six getting through and the database
		 * clean running six times. A persistent object cache widens it
		 * further: get_option() keeps answering from cache after the row is
		 * gone, so the read cannot be the gate under any timing.
		 *
		 * A single DELETE is the only step here MySQL makes atomic. InnoDB
		 * takes a row lock, exactly one statement reports a row affected, and
		 * every other concurrent caller sees zero however they got here. So
		 * the row's disappearance IS the permission, and the read above is
		 * demoted to what it should always have been — a way to recover the
		 * fingerprint, not evidence of anything.
		 *
		 * Raw rather than delete_option() because delete_option() reports
		 * whether it thinks a row existed, not whether THIS caller removed
		 * it, and it decides that from a cache. rows_affected comes from the
		 * server.
		 */
		// No database, no atomic claim, no confirmation. Refusing here costs a
		// caller one retry; the alternative is granting permission on the
		// strength of a read that was never proof of anything.
		if ( ! $wpdb instanceof \wpdb && ! is_object( $wpdb ) ) {
			return '';
		}

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- the atomic claim needs rows_affected from the server; the cache entry is dropped right after.
		$claimed = $wpdb->query(
			$wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name = %s", $key )
		);

		// Whoever won, the row is gone for everyone; a cache still holding it
		// would let a later read look live.
		wp_cache_delete( $key, 'options' );

		if ( 1 !== (int) $claimed ) {
			return '';
		}

		$data = json_decode( $stored, true );
		if ( ! is_array( $data ) || empty( $data['fingerprint'] ) ) {
			return '';
		}
		if ( ! isset( $data['expires'] ) || time() > (int) $data['expires'] ) {
			return '';
		}

		return (string) $data['fingerprint'];
	}

	/**
	 * Drop token rows nobody consumed.
	 *
	 * Options have no TTL, so an unused token would otherwise sit in
	 * wp_options forever — a scan that is never followed by a clean is the
	 * normal case, not the exception.
	 */
	private static function purge_expired_confirm_tokens(): void {
		global $wpdb;

		if ( ! isset( $wpdb ) || ! is_object( $wpdb ) ) {
			return;
		}

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- no options API for "select by key prefix"; runs only when a token is minted.
		$names = $wpdb->get_col(
			$wpdb->prepare(
				"SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s",
				$wpdb->esc_like( 'xspeed_mcp_clean_' ) . '%'
			)
		);

		foreach ( (array) $names as $name ) {
			$data = json_decode( (string) get_option( $name ), true );
			if ( ! is_array( $data ) || ! isset( $data['expires'] ) || time() > (int) $data['expires'] ) {
				delete_option( $name );
			}
		}
	}

	/**
	 * Clean database bloat (destructive).
	 *
	 * @param array $args Unused.
	 * @return array|\WP_Error
	 */
	public static function clean_database( array $args ) {
		/*
		 * The scan-before-clean confirmation is enforced in invoke(), which
		 * every tool passes through — see destructive_action(). It is NOT
		 * repeated here: the token is single-use, so checking it twice would
		 * consume it on the first check and reject the caller on the second.
		 *
		 * Reaching this line means the dispatcher already verified a token
		 * bound to a scan of the current database state. (#184)
		 */
		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 ) );
		}
		$assoc = array();
		if ( $on && ! empty( $args['takeover'] ) && filter_var( $args['takeover'], FILTER_VALIDATE_BOOLEAN ) ) {
			$assoc['takeover'] = true;
		}
		if ( ! $on && ! empty( $args['restore'] ) && filter_var( $args['restore'], FILTER_VALIDATE_BOOLEAN ) ) {
			$assoc['restore'] = true;
		}
		return Cli_Bridge::run( 'objcache', array( $on ? 'enable' : 'disable' ), $assoc );
	}

	/**
	 * 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 ) );

		$history = \XSpeed\Score::history();

		$runs = array();
		foreach ( array_slice( $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,
				// A failed audit and a successful one that returned no score
				// both project to score null — ok is the only field that
				// tells them apart, and error says why it failed.
				'ok'         => ! empty( $run['ok'] ),
				'error'      => isset( $run['error'] ) && '' !== $run['error'] ? (string) $run['error'] : 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( $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 an external audit on any install.
	 *
	 * Shares run_pagespeed's body: that handler ALREADY falls back to
	 * `xspeed score run` when the Pro `xspeed psi` command is absent, so the
	 * engine could always do this on Free — the tool was simply dropped from
	 * the catalog before anyone could call it. The only thing missing was a
	 * name that survives on a Free install. (#147)
	 *
	 * @param array $args target / strategy / provider.
	 * @return array|\WP_Error
	 */
	public static function run_score( array $args ) {
		// `target` is the CLI's name for it (--url is a reserved WP-CLI global,
		// so the score command deliberately uses --target). Accept both here
		// and normalise, so an assistant that guessed `url` still works.
		if ( ! empty( $args['target'] ) && empty( $args['url'] ) ) {
			$args['url'] = (string) $args['target'];
		}
		return self::run_pagespeed( $args );
	}

	/**
	 * Run an external performance audit. Prefers the Pro engine when present,
	 * otherwise drives Free's own score command.
	 *
	 * @param array $args { url?:string, strategy?:string, provider?:string, force?:bool }.
	 * @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'];
		}
		// Advertised in run_score's schema, and the Free score handler already
		// branches on it (ScoreModule::cli_handler reads $assoc['provider']),
		// so dropping it here meant a GTmetrix request ran a PSI audit and
		// reported ok:true — spending the wrong provider's quota with nothing
		// in the response to say so. (QA B1 on #162)
		if ( ! empty( $args['provider'] ) ) {
			$options['provider'] = (string) $args['provider'];
		}
		// 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.
		 *
		 * EXCEPT when a provider was named that the Pro engine cannot serve.
		 * `xspeed psi` is PageSpeed-only: it declares no --provider and
		 * discards the option, so preferring it purely because it exists made
		 * `provider: "gtmetrix"` run PSI and answer ok:true — the same silent
		 * wrong-provider bug this tool just fixed on Free, reappearing only on
		 * Pro. A site that configures GTmetrix would have stopped getting it
		 * the moment Pro activated. Free's `score` command reads $assoc
		 * ['provider'] and branches, so route there instead. (QA R1 on #162)
		 */
		$wants_non_psi = isset( $options['provider'] ) && 'psi' !== strtolower( (string) $options['provider'] );
		if ( isset( Cli_Bridge::commands()['xspeed psi'] ) && ! $wants_non_psi ) {
			return Cli_Bridge::run( 'psi', array(), $options );
		}

		// The Free `score` command reads --target, not --url: `url` is a
		// reserved WP-CLI global, so a value passed as `url` never reaches the
		// handler and the requested page is silently ignored in favour of the
		// default. Translate rather than passing it through. (#147)
		if ( isset( $options['url'] ) ) {
			$options['target'] = $options['url'];
			unset( $options['url'] );
		}
		return Cli_Bridge::run( 'score', array( 'run' ), $options );
	}

	/**
	 * Generate Critical CSS (Pro).
	 *
	 * The tool took no arguments, so it could only build the home page's
	 * blob, while `wp xspeed ccss generate --page-url` could target any page.
	 * Passed as `page-url`: `url` is a WP-CLI global the command never sees
	 * from a real command line. (#559)
	 *
	 * @param array $args { url?:string } Full URL or site path.
	 * @return array|\WP_Error
	 */
	public static function generate_critical_css( array $args ) {
		$options = array();
		if ( ! empty( $args['url'] ) ) {
			$url = trim( (string) $args['url'] );
			// A site path is a page on this site, as it is for run_pagespeed.
			if ( '/' === substr( $url, 0, 1 ) && '//' !== substr( $url, 0, 2 ) ) {
				$url = home_url( $url );
			} elseif ( ! preg_match( '#^https?://#i', $url ) ) {
				$url = ''; // `about/`, `//host/x`: neither a path nor a full URL.
			}
			$url = '' === $url ? '' : esc_url_raw( $url, array( 'http', 'https' ) );
			// Only pages of this site. A render spends the site's quota, and
			// another host is not a page this site's Critical CSS can serve.
			$host = strtolower( (string) wp_parse_url( $url, PHP_URL_HOST ) );
			if ( '' !== $url && strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) ) !== $host ) {
				$url = '';
			}
			// Refused rather than dropped: an empty value would quietly
			// build the home page and report success for the wrong page.
			if ( '' === $url ) {
				return new \WP_Error( 'xspeed_mcp_invalid_url', __( 'The url argument must be a page on this site, as a full http(s) URL or a path starting with /.', 'xspeed' ), array( 'status' => 400 ) );
			}
			$options['page-url'] = $url;
		}
		return Cli_Bridge::run( 'ccss', array( 'generate' ), $options );
	}

	/**
	 * 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;
	}
}

```
