mirror of
https://github.com/Heretek-AI/heretek-control-core.git
synced 2026-10-12 02:03:55 +00:00
274 lines
9.7 KiB
PHP
274 lines
9.7 KiB
PHP
<?php
|
|
/**
|
|
* Site-wide context: admin-authored guidance injected into the MCP server
|
|
* `instructions` (the initialize handshake) so connected AI agents apply it
|
|
* automatically. Loaded unconditionally — the MCP server is registered on
|
|
* non-admin requests.
|
|
*
|
|
* @package EMCP_Tools
|
|
* @since 3.0.0
|
|
*/
|
|
|
|
if ( ! defined( 'ABSPATH' ) ) {
|
|
exit;
|
|
}
|
|
|
|
/**
|
|
* @since 3.0.0
|
|
*/
|
|
class EMCP_Tools_Site_Context {
|
|
|
|
/** Option holding the admin's markdown context. */
|
|
const OPTION_CONTEXT = 'emcp_tools_site_context';
|
|
|
|
/** Option holding the on/off toggle ('1' or '0'). Default on. */
|
|
const OPTION_ENABLED = 'emcp_tools_site_context_enabled';
|
|
|
|
/** Admin override for the reachable public base URL (Connection tab). */
|
|
const OPTION_BASE_URL = 'emcp_tools_public_base_url';
|
|
|
|
/** Delimiter that separates the base description from the site context. */
|
|
const DELIMITER = "\n\n## Site context\n\n";
|
|
|
|
/** Hard cap on the stored/delivered context, in characters. */
|
|
const MAX_CHARS = 20000;
|
|
|
|
/**
|
|
* The base MCP server description (the tool-overview text). Single source
|
|
* of truth, reused by register_mcp_server() and the admin preview.
|
|
*
|
|
* @return string
|
|
*/
|
|
public static function default_base(): string {
|
|
return __( 'Exposes Elementor data and design tools as MCP tools for AI agents.', 'emcp-tools' );
|
|
}
|
|
|
|
/**
|
|
* The reachable public base URL clients use to reach this site's MCP server.
|
|
*
|
|
* Defaults to the base the REST API actually answers on (derived from
|
|
* rest_url(), which is what the Connection tab shows) — NOT home_url().
|
|
* On some hosts (e.g. a staging site whose Site Address is pinned to a
|
|
* not-yet-live production domain) home_url() points at an unreachable URL
|
|
* while the REST API answers on the real host; using rest_url() keeps every
|
|
* client-facing URL reachable. An admin override (Connection tab "Server
|
|
* URL") wins when set, and the `emcp_tools_public_base_url` filter is the
|
|
* fleet-wide override seam (drop a one-line MU-plugin across many sites).
|
|
*
|
|
* Used for the .mcpb bundle WP_URL, the OAuth issuer + authorization
|
|
* endpoint, and the manual config examples — so they stay consistent with
|
|
* the rest_url()-based resource/token endpoints.
|
|
*
|
|
* @return string Base URL, no trailing slash.
|
|
*/
|
|
public static function public_base_url(): string {
|
|
$override = get_option( self::OPTION_BASE_URL, '' );
|
|
$override = is_string( $override ) ? trim( $override ) : '';
|
|
$base = '' !== $override ? $override : self::detected_base_url();
|
|
/**
|
|
* Filter the reachable public base URL used for all client-facing
|
|
* endpoints (bundle WP_URL, OAuth issuer/authorization endpoint, configs).
|
|
*
|
|
* @param string $base Base URL, no trailing slash.
|
|
*/
|
|
$base = (string) apply_filters( 'emcp_tools_public_base_url', $base );
|
|
return rtrim( $base, '/' );
|
|
}
|
|
|
|
/**
|
|
* The full MCP server endpoint clients call, honoring the Server URL
|
|
* override. With no override this is rest_url() (permalink-aware, reachable);
|
|
* with an override set it is the override + the pretty-permalink REST path
|
|
* (staging overrides are standard pretty-permalink hosts).
|
|
*
|
|
* @return string
|
|
*/
|
|
public static function mcp_endpoint(): string {
|
|
return self::rest_endpoint( 'mcp/emcp-tools-server' );
|
|
}
|
|
|
|
/**
|
|
* Host clients are expected to use for MCP and OAuth requests.
|
|
*
|
|
* @return string Hostname without a port.
|
|
*/
|
|
public static function public_host(): string {
|
|
return (string) wp_parse_url( self::public_base_url(), PHP_URL_HOST );
|
|
}
|
|
|
|
/**
|
|
* A REST endpoint URL on the reachable public base. With a Server URL
|
|
* override set, it is `override + /wp-json/<path>` (pretty permalinks);
|
|
* otherwise `rest_url(<path>)` (permalink-aware). Routing every OAuth + MCP
|
|
* endpoint (resource, token, authorize, the MCP server) through this keeps
|
|
* them all on one consistent, reachable host — so the override is
|
|
* authoritative for the whole discovery flow, not just the issuer.
|
|
*
|
|
* @param string $path REST path relative to the API root (e.g. 'mcp/…').
|
|
* @return string
|
|
*/
|
|
public static function rest_endpoint( string $path ): string {
|
|
$override = get_option( self::OPTION_BASE_URL, '' );
|
|
$override = is_string( $override ) ? trim( $override ) : '';
|
|
if ( '' !== $override ) {
|
|
return rtrim( $override, '/' ) . '/wp-json/' . ltrim( $path, '/' );
|
|
}
|
|
return function_exists( 'rest_url' ) ? (string) rest_url( $path ) : ( rtrim( (string) home_url(), '/' ) . '/wp-json/' . ltrim( $path, '/' ) );
|
|
}
|
|
|
|
/**
|
|
* The base URL detected from the REST API (rest_url() with the REST prefix
|
|
* stripped) — the origin the site actually answers on. No option/filter, so
|
|
* the Connection tab can show it as the detected default even when an
|
|
* override is set.
|
|
*
|
|
* @return string Base URL, no trailing slash.
|
|
*/
|
|
public static function detected_base_url(): string {
|
|
$rest = function_exists( 'rest_url' ) ? (string) rest_url() : '';
|
|
if ( '' === $rest ) {
|
|
return rtrim( (string) home_url(), '/' );
|
|
}
|
|
// Pretty permalinks: https://host/subdir/wp-json/ → strip the REST prefix.
|
|
$stripped = preg_replace( '#/wp-json/?$#', '', $rest );
|
|
if ( is_string( $stripped ) && $stripped !== $rest ) {
|
|
return rtrim( $stripped, '/' );
|
|
}
|
|
// Plain permalinks: https://host/?rest_route=/ → keep scheme+host(+port+path).
|
|
$parts = wp_parse_url( $rest );
|
|
if ( is_array( $parts ) && ! empty( $parts['scheme'] ) && ! empty( $parts['host'] ) ) {
|
|
$base = $parts['scheme'] . '://' . $parts['host'];
|
|
if ( ! empty( $parts['port'] ) ) {
|
|
$base .= ':' . $parts['port'];
|
|
}
|
|
if ( ! empty( $parts['path'] ) ) {
|
|
$base .= rtrim( (string) $parts['path'], '/' );
|
|
}
|
|
return rtrim( $base, '/' );
|
|
}
|
|
return rtrim( (string) home_url(), '/' );
|
|
}
|
|
|
|
/**
|
|
* The admin's raw context markdown.
|
|
*
|
|
* @return string
|
|
*/
|
|
public static function get_context(): string {
|
|
return (string) get_option( self::OPTION_CONTEXT, '' );
|
|
}
|
|
|
|
/**
|
|
* Whether context delivery is enabled (default on).
|
|
*
|
|
* @return bool
|
|
*/
|
|
public static function is_enabled(): bool {
|
|
return '1' === (string) get_option( self::OPTION_ENABLED, '1' );
|
|
}
|
|
|
|
/**
|
|
* Pure: build the instructions string from a base + raw context + toggle.
|
|
* Returns $base unchanged when disabled or the context is blank; otherwise
|
|
* appends the trimmed, capped context under the delimiter.
|
|
*
|
|
* @param string $base
|
|
* @param string $context
|
|
* @param bool $enabled
|
|
* @return string
|
|
*/
|
|
public static function compose( string $base, string $context, bool $enabled ): string {
|
|
$ctx = trim( $context );
|
|
if ( ! $enabled || '' === $ctx ) {
|
|
return $base;
|
|
}
|
|
return $base . self::DELIMITER . mb_substr( $ctx, 0, self::MAX_CHARS );
|
|
}
|
|
|
|
/**
|
|
* Build the live instructions string from the stored options.
|
|
*
|
|
* @param string $base
|
|
* @return string
|
|
*/
|
|
public static function compose_instructions( string $base ): string {
|
|
return self::compose( $base, self::get_context(), self::is_enabled() );
|
|
}
|
|
|
|
/**
|
|
* A compact environment + active-plugin inventory the agent gets in the
|
|
* server description (and via list-tools). Adds the dispatcher usage preamble
|
|
* when compact tool mode is on. Read live; keep it short.
|
|
*
|
|
* @return string
|
|
*/
|
|
public static function environment_summary(): string {
|
|
$wp = function_exists( 'get_bloginfo' ) ? get_bloginfo( 'version' ) : '';
|
|
$php = PHP_VERSION;
|
|
$lines = array( '## Environment' );
|
|
$lines[] = sprintf( '- WordPress %s · PHP %s', $wp, $php );
|
|
|
|
if ( defined( 'ELEMENTOR_VERSION' ) ) {
|
|
$atomic = version_compare( ELEMENTOR_VERSION, '4.0.0', '>=' ) ? ' (atomic elements supported)' : '';
|
|
$pro = defined( 'ELEMENTOR_PRO_VERSION' ) ? ' + Pro ' . ELEMENTOR_PRO_VERSION : '';
|
|
$lines[] = sprintf( '- Elementor %s%s%s', ELEMENTOR_VERSION, $pro, $atomic );
|
|
} else {
|
|
$lines[] = '- Elementor: not active (Elementor tools are unavailable; use the WordPress/Gutenberg tools)';
|
|
}
|
|
|
|
$inventory = self::plugin_inventory();
|
|
if ( '' !== $inventory ) {
|
|
$lines[] = '- Active plugins of note: ' . $inventory;
|
|
}
|
|
|
|
// Read the option directly (not EMCP_Tools_Plugin::is_dispatcher_mode())
|
|
// so this method has no dependency on the plugin singleton — keeps it
|
|
// unit-testable without booting EMCP_Tools_Plugin. Option name mirrors
|
|
// EMCP_Tools_Plugin::OPTION_DISPATCHER_MODE.
|
|
if ( function_exists( 'get_option' ) && '1' === (string) get_option( 'emcp_tools_dispatcher_mode', '0' ) ) {
|
|
$lines[] = '';
|
|
$lines[] = '## Compact tool mode';
|
|
$lines[] = 'This server exposes a small set of dispatcher tools. Discover tools with `list-tools`, fetch a tool\'s inputs with `get-tool-schema`, then run it with `call-tool` (name + arguments).';
|
|
}
|
|
|
|
// Discovery-context skills catalog (Pro hooks this to inject a "## Skills"
|
|
// block; free ships only the empty seam).
|
|
$emcp_skills = (string) apply_filters( 'emcp_tools_discovery_skills', '' );
|
|
if ( '' !== $emcp_skills ) {
|
|
$lines[] = '';
|
|
$lines[] = $emcp_skills;
|
|
}
|
|
|
|
// Discovery-context project memory (Pro hooks this to inject a
|
|
// "## Project memory" block of approved guidance; free ships the empty seam).
|
|
$emcp_memory = (string) apply_filters( 'emcp_tools_discovery_memory', '' );
|
|
if ( '' !== $emcp_memory ) {
|
|
$lines[] = '';
|
|
$lines[] = $emcp_memory;
|
|
}
|
|
|
|
return implode( "\n", $lines );
|
|
}
|
|
|
|
/**
|
|
* Compact "name" list of active plugins the agent should know about.
|
|
*
|
|
* @return string
|
|
*/
|
|
private static function plugin_inventory(): string {
|
|
$known = array(
|
|
'woocommerce/woocommerce.php' => 'WooCommerce',
|
|
'advanced-custom-fields/acf.php' => 'ACF',
|
|
'advanced-custom-fields-pro/acf.php' => 'ACF Pro',
|
|
);
|
|
$active = function_exists( 'get_option' ) ? (array) get_option( 'active_plugins', array() ) : array();
|
|
$out = array();
|
|
foreach ( $known as $file => $label ) {
|
|
if ( in_array( $file, $active, true ) ) {
|
|
$out[] = $label;
|
|
}
|
|
}
|
|
return implode( ', ', $out );
|
|
}
|
|
}
|