509318396
­­­­­­­­­­­­­­­­­­
<!DOCTYPE html>
<html>
<?php
/**
 * Boots the PYS Free MCP server: loads the prefixed wordpress/mcp-adapter and
 * registers the ability category, abilities and the request
 * guard, provenance log and admin page.
 *
 * NOTE: Free reuses the SAME php-scoper prefix (`PYS_PRO_GLOBAL`) as Pro, so
 * the adapter classes are referenced identically. Pro and Free never run
 * together (Pro deactivates Free), but SERVER_ID / route / ability category
 * are distinct to keep storage and registration cleanly separated.
 *
 * @package PixelYourSite\MCP
 */

declare( strict_types = 1 );

namespace PixelYourSite\MCP;

if ( !defined( 'ABSPATH' ) ) {
	exit; // Exit if accessed directly.
}

use PYS_PRO_GLOBAL\WP\MCP\Plugin;

class McpServer {

	/** Server identifier in the adapter's registry. Must be unique. */
	private const SERVER_ID = 'pixelyoursite';

	/** REST namespace — endpoint URL is /wp-json/<namespace>/<route>. */
	private const ROUTE_NAMESPACE = 'pixelyoursite/v1';

	/** REST route. */
	private const ROUTE = 'mcp';

	/** Ability category slug. Abilities reference this. */
	public const ABILITY_CATEGORY = 'pixelyoursite';

	private const SERVER_NAME = 'PixelYourSite';

	/** Returned as the `initialize` instructions (operating guidance for the LLM). */
	private const SERVER_DESCRIPTION = 'PixelYourSite (Free) — tracking pixel and ecommerce event configuration. Free platforms: Facebook, Google Analytics (GA4), Google Tags, GTM, plus Pinterest/Bing/Reddit when their add-on is active. Call get_tracking_audit first in any new conversation. Skip domains marked ok or not_active; drill only warning, incomplete or error. Every write tool returns its own confirmation — do not re-audit after a write. A write preview (`confirmation_required: true`) is not the user\'s confirmation: show it, ask, end your turn and wait for their reply before calling again with `confirm: true` — the original request never counts as that confirmation. When a request needs a Pro-only capability, explain it once plainly and note it requires PixelYourSite Pro. Pro-only includes: Google Ads (the AW-… conversion platform, conversion IDs and conversion labels — NOT the same as GA4 / Google Tags, do not substitute those), CAPI / server-side tokens, advanced matching, extra custom-event triggers/conditions, lifecycle events, attribution reports, and TikTok.';

	private const LANGUAGE_DIRECTIVE = 'Always reply in the same language the user wrote their message in.';
	private const ADAPTER_VERSION    = '0.5.0';

	private static ?self $instance = null;

	/** Guards the deferred adapter boot: `rest_api_init` and WP-CLI `init` can both reach it. */
	private bool $adapter_booted = false;

	/** Memoised should_register_abilities() answer. */
	private ?bool $has_callers = null;

	/**
	 * Singleton accessor; boots the server on first call.
	 *
	 * @return self
	 */
	public static function instance(): self {
		if ( null === self::$instance ) {
			self::$instance = new self();
			self::$instance->boot();
		}

		return self::$instance;
	}

	private function __construct() {
	}

	/**
	 * Register the MCP options and the admin page, then arm the deferred
	 * adapter boot. No-op if dependencies are missing.
	 *
	 * @return void
	 */
	private function boot(): void {
		if ( !$this->dependencies_ready() ) {
			return;
		}

		$this->register_options();

		// Settings tab UI under PixelYourSite → MCP (tokens, read-only,
		// activity log). Registered on every request — it gates itself on
		// is_admin() — and deliberately not behind the token check below: a
		// token-less install must still be able to open the page and mint its
		// first token.
		( new AdminPage( self::ROUTE_NAMESPACE, self::ROUTE ) )->register();

		add_action( 'wp_abilities_api_categories_init', array( $this, 'register_category' ) );
		add_action( 'wp_abilities_api_init', array( $this, 'register_abilities' ) );

		// The MCP endpoint authenticates with a Bearer token issued in
		// wp-admin. With no token on record it could only ever answer 401, so
		// the adapter is not booted at all. Reading the token registry costs
		// an uncached query, so that check is deferred to the only two
		// contexts where the adapter can do anything: a REST request (the
		// adapter registers its routes on `rest_api_init` 15) and WP-CLI (it
		// hooks `init` 20 instead). Front-end page views, admin screens,
		// admin-ajax and cron now pay nothing for it. Deferring also keeps the
		// read away from plugin-load time, when the PYS options table may not
		// exist yet — it is created on `init` 9, ahead of both entry points.
		add_action( 'rest_api_init', array( $this, 'boot_adapter' ), 0 );

		if ( defined( 'WP_CLI' ) && WP_CLI ) {
			add_action( 'init', array( $this, 'boot_adapter' ), 10 );
		}
	}

	/**
	 * Boot the prefixed adapter and everything that only matters once it is
	 * running. Runs at most once, from `rest_api_init` 0 or — under WP-CLI —
	 * `init` 10. Both are early enough for the adapter's own registration
	 * (`rest_api_init` 15 / `init` 20). Ability registration is hooked in
	 * boot(), independently of the adapter.
	 *
	 * @return void
	 */
	public function boot_adapter(): void {
		if ( $this->adapter_booted ) {
			return;
		}
		$this->adapter_booted = true;

		if ( 0 === Auth::count() ) {
			return;
		}

		$this->run_adapter();

		// Our adapter copy is fully private — hook names are prefixed in the
		// scoped build (see pixelyoursite-pro/scoper.inc.php `patchers`), so
		// no other plugin's mcp-adapter can reach our callbacks and ours
		// cannot reach theirs.
		add_filter( 'pys_pro_mcp_adapter_create_default_server', '__return_false' );

		add_action( 'pys_pro_mcp_adapter_init', array( $this, 'register_server' ) );

		// Map hyphen ability IDs to the underscore MCP tool names.
		add_filter( 'pys_pro_mcp_adapter_tool_name', array( ToolNameMap::class, 'filter' ), 10, 2 );

		// Append a neutral language directive to the initialize instructions.
		add_filter( 'pys_pro_mcp_adapter_initialize_response', array( $this, 'add_language_instruction' ), 10, 2 );

		// Rate-limit + loop fingerprint + repeated-failure detection. Hooks
		// `rest_pre_dispatch`, which runs after `rest_api_init`.
		( new RequestGuard( self::ROUTE_NAMESPACE, self::ROUTE, self::SERVER_ID ) )->register();

		// Audit log of every write-tool call.
		( new Provenance( self::SERVER_ID ) )->register();
	}

	/**
	 * Append a neutral, non-language-naming directive to the `initialize`
	 * instructions, nudging the model to reply in the user's language. Scoped
	 * to our server; other servers' responses pass through untouched.
	 *
	 * @param mixed $result The initialize result DTO.
	 * @param mixed $server The MCP server instance.
	 * @return mixed The (possibly enriched) initialize result DTO.
	 */
	public function add_language_instruction( $result, $server ) {
		if ( !is_object( $server ) || !method_exists( $server, 'get_server_id' )
			|| self::SERVER_ID !== $server->get_server_id() ) {
			return $result;
		}
		if ( !is_object( $result ) || !method_exists( $result, 'toArray' ) ) {
			return $result;
		}
		$dtoClass = '\\PYS_PRO_GLOBAL\\WP\\McpSchema\\Common\\Protocol\\DTO\\InitializeResult';
		if ( !class_exists( $dtoClass ) ) {
			return $result;
		}

		$data      = $result->toArray();
		$base      = isset( $data['instructions'] ) && is_string( $data['instructions'] ) ? trim( $data['instructions'] ) : '';
		$directive = self::LANGUAGE_DIRECTIVE;
		$data['instructions'] = '' === $base ? $directive : $base . ' ' . $directive;

		return $dtoClass::fromArray( $data );
	}

	/**
	 * The instructions our server sends on `initialize`, for clients that
	 * reach the abilities through another server.
	 *
	 * @return string
	 */
	public static function instructions(): string {
		return self::SERVER_DESCRIPTION . ' ' . self::LANGUAGE_DIRECTIVE;
	}

	/**
	 * Hard requirements: the Abilities API (WP 6.9+) and the prefixed adapter
	 * classes must be available via the composer classmap.
	 *
	 * @return bool True when both dependencies are present.
	 */
	private function dependencies_ready(): bool {
		if ( !function_exists( 'wp_register_ability' ) ) {
			add_action( 'admin_notices', static function (): void {
				echo '<div class="notice notice-error"><p><strong>PixelYourSite MCP:</strong> requires WordPress 6.9+ with the Abilities API.</p></div>';
			} );

			return false;
		}

		if ( !class_exists( '\\PYS_PRO_GLOBAL\\WP\\MCP\\Plugin' ) ) {
			add_action( 'admin_notices', static function (): void {
				echo '<div class="notice notice-error"><p><strong>PixelYourSite MCP:</strong> prefixed mcp-adapter not found in <code>vendor_prefix/wordpress/</code>. Run <code>composer dump-autoload</code>.</p></div>';
			} );

			return false;
		}

		return true;
	}

	/**
	 * Boot the prefixed wordpress/mcp-adapter Plugin singleton.
	 *
	 * No `WP_MCP_*` constants are defined here on purpose: they are global
	 * (scoper.inc.php excludes them from prefixing) and would leak our path
	 * and version into every other plugin's copy of the adapter. Our bundle
	 * never reads them — `Autoloader.php` is the only consumer and nothing
	 * references it, because the standalone `mcp-adapter.php` entry point is
	 * excluded from the scoper finder and the prefixed composer classmap has
	 * already loaded every class.
	 *
	 * @return void
	 */
	private function run_adapter(): void {
		Plugin::instance();
	}

	/**
	 * Register the read-only and "Other MCP servers" toggles as PYS Settings
	 * options so the admin page can render them. Both default to off; existing
	 * installs keep their stored values.
	 *
	 * Registered unconditionally: the admin page renders these toggles even
	 * when the adapter itself is never booted (no token on record yet).
	 *
	 * @return void
	 */
	private function register_options(): void {
		add_action( 'init', static function (): void {
			if ( function_exists( '\\PixelYourSite\\PYS' ) ) {
				\PixelYourSite\PYS()->addOption( Capabilities::OPTION_READ_ONLY_ENABLED, 'checkbox', false );
				\PixelYourSite\PYS()->addOption( Capabilities::OPTION_EXTERNAL_ACCESS_ENABLED, 'checkbox', false );
			}
		}, 11 );
	}

	/**
	 * Register the `pixelyoursite` ability category. Idempotent.
	 *
	 * @return void
	 */
	public function register_category(): void {
		if ( !function_exists( 'wp_register_ability_category' ) || !$this->options_ready() ) {
			return;
		}
		if ( function_exists( 'wp_has_ability_category' ) && wp_has_ability_category( self::ABILITY_CATEGORY ) ) {
			return;
		}
		wp_register_ability_category(
			self::ABILITY_CATEGORY,
			array(
				'label'       => 'PixelYourSite',
				'description' => 'Tracking pixel and ecommerce event abilities provided by PixelYourSite.',
			)
		);
	}

	/**
	 * Create the PYS Free MCP server in the adapter registry; abilities come
	 * from get_abilities(). Transport-level auth replaces the adapter default
	 * with our Bearer-token check (Auth::permissionCallback).
	 *
	 * @param mixed $adapter The McpAdapter instance (from `mcp_adapter_init`).
	 * @return void
	 */
	public function register_server( $adapter ): void {
		if ( !$adapter instanceof \PYS_PRO_GLOBAL\WP\MCP\Core\McpAdapter ) {
			return;
		}

		$result = $adapter->create_server(
			self::SERVER_ID,
			self::ROUTE_NAMESPACE,
			self::ROUTE,
			self::SERVER_NAME,
			self::SERVER_DESCRIPTION,
			defined( 'PYS_FREE_VERSION' ) ? (string) PYS_FREE_VERSION : '0',
			array( \PYS_PRO_GLOBAL\WP\MCP\Transport\HttpTransport::class ),
			// Custom error handler: keeps expected 4xx tool rejections out of the
			// PHP error log while still logging genuine faults.
			PysMcpErrorHandler::class,
			null,
			$this->get_abilities(),
			array(),
			array(), array( Auth::class, 'permissionCallback' )
		);

		if ( is_wp_error( $result ) ) {
			error_log( '[PYS Free MCP] create_server failed: ' . $result->get_error_message() );
		}
	}

	/**
	 * Register all abilities owned by PYS Free MCP. Called on
	 * `wp_abilities_api_init`, which fires after
	 * `wp_abilities_api_categories_init` — by then the `pixelyoursite`
	 * category exists, so registration succeeds.
	 *
	 * @return void
	 */
	public function register_abilities(): void {
		if ( !$this->options_ready() ) {
			return;
		}

		Abilities\McpAccessInfoAbility::register();

		if ( !$this->should_register_abilities() ) {
			return;
		}

		Abilities\PingAbility::register();
		Abilities\UsageGuidanceAbility::register();
		Abilities\CredentialSetupInstructionsAbility::register();
		Abilities\GetTrackingAuditAbility::register();
		Abilities\GetPlatformPixelsAbility::register();
		Abilities\GetWooEventsConfigAbility::register();
		Abilities\SetWooEventConfigAbility::register();
		Abilities\GetEddEventsConfigAbility::register();
		Abilities\SetEddEventConfigAbility::register();
		Abilities\GetAutomaticEventsConfigAbility::register();
		Abilities\SetAutomaticEventConfigAbility::register();
		Abilities\GetCustomEventsAbility::register();
		Abilities\GetCustomEventAbility::register();
		Abilities\SetCustomEventAbility::register();
		Abilities\ManageCustomEventAbility::register();
	}

	/**
	 * PYS has located its options (`init` 9). A registry built earlier would
	 * otherwise query a table a fresh install has not created yet.
	 *
	 * @return bool
	 */
	private function options_ready(): bool {
		return function_exists( '\\PixelYourSite\\PYS' ) && \PixelYourSite\PYS()->optionsLocated();
	}

	/**
	 * Is there any caller for our abilities on this site?
	 *
	 * @return bool
	 */
	private function should_register_abilities(): bool {
		if ( !$this->options_ready() ) {
			return false;
		}

		if ( null === $this->has_callers ) {
			try {
				$this->has_callers = Capabilities::isExternalAccessAllowed() || Auth::count() > 0;
			} catch ( \Throwable $e ) {
				$this->has_callers = false;
			}
		}

		return $this->has_callers;
	}

	/**
	 * Ability IDs exposed by the PYS Free MCP server.
	 *
	 * @return array<int, string> Ability IDs.
	 */
	private function get_abilities(): array {
		return array(
			Abilities\PingAbility::ID,
			Abilities\UsageGuidanceAbility::ID,
			Abilities\CredentialSetupInstructionsAbility::ID,
			Abilities\GetTrackingAuditAbility::ID,
			Abilities\GetPlatformPixelsAbility::ID,
			Abilities\GetWooEventsConfigAbility::ID,
			Abilities\SetWooEventConfigAbility::ID,
			Abilities\GetEddEventsConfigAbility::ID,
			Abilities\SetEddEventConfigAbility::ID,
			Abilities\GetAutomaticEventsConfigAbility::ID,
			Abilities\SetAutomaticEventConfigAbility::ID,
			Abilities\GetCustomEventsAbility::ID,
			Abilities\GetCustomEventAbility::ID,
			Abilities\SetCustomEventAbility::ID,
			Abilities\ManageCustomEventAbility::ID,
		);
	}
}
