/home/altere25/sportzsoftlivemeet.com/wp-content/plugins/stifli-flex-mcp
Edit: /home/altere25/sportzsoftlivemeet.com/wp-content/plugins/stifli-flex-mcp/mod.php (265273B)
prefix)) {
$this->queueTable = StifliFlexMcpUtils::getPrefixedTable('sflmcp_queue', false);
}
}
public function init() {
add_action('rest_api_init', array($this, 'restApiInit'));
add_filter('rest_post_dispatch', array($this, 'addNoCacheHeadersForNamespace'), 9, 3);
add_action('sflmcp_process_task', array($this, 'processTaskAsync'), 10, 1);
$this->ensureDispatcherCallbackRegistered();
// Register admin menu and settings when in WP admin
if (is_admin()) {
add_action('admin_menu', array($this, 'registerAdmin'));
add_action('admin_menu', array($this, 'registerMcpServerSubmenu'), 15);
add_action('admin_menu', array($this, 'registerMultimediaSubmenu'), 25);
add_action('admin_init', array($this, 'registerSettings'));
add_action('admin_init', array($this, 'handleOAuthWellKnownNoticeDismiss'));
add_action('admin_notices', array($this, 'renderOAuthWellKnownNotice'));
add_action('admin_enqueue_scripts', array($this, 'enqueueAdminScripts'));
// AJAX handlers for profiles management
add_action('wp_ajax_sflmcp_create_profile', array($this, 'ajax_create_profile'));
add_action('wp_ajax_sflmcp_update_profile', array($this, 'ajax_update_profile'));
add_action('wp_ajax_sflmcp_delete_profile', array($this, 'ajax_delete_profile'));
add_action('wp_ajax_sflmcp_duplicate_profile', array($this, 'ajax_duplicate_profile'));
add_action('wp_ajax_sflmcp_apply_profile', array($this, 'ajax_apply_profile'));
add_action('wp_ajax_sflmcp_export_profile', array($this, 'ajax_export_profile'));
add_action('wp_ajax_sflmcp_import_profile', array($this, 'ajax_import_profile'));
add_action('wp_ajax_sflmcp_restore_system_profiles', array($this, 'ajax_restore_system_profiles'));
// AJAX handlers for custom tools
add_action('wp_ajax_sflmcp_get_custom_tools', array($this, 'ajax_get_custom_tools'));
add_action('wp_ajax_sflmcp_save_custom_tool', array($this, 'ajax_save_custom_tool'));
add_action('wp_ajax_sflmcp_delete_custom_tool', array($this, 'ajax_delete_custom_tool'));
add_action('wp_ajax_sflmcp_test_custom_tool', array($this, 'ajax_test_custom_tool'));
add_action('wp_ajax_sflmcp_toggle_custom_tool', array($this, 'ajax_toggle_custom_tool'));
// AJAX handlers for WordPress/WooCommerce tools
add_action('wp_ajax_sflmcp_toggle_tool', array($this, 'ajax_toggle_tool'));
add_action('wp_ajax_sflmcp_bulk_toggle_tools', array($this, 'ajax_bulk_toggle_tools'));
add_action('wp_ajax_sflmcp_toggle_tool_by_checkbox', array($this, 'ajax_toggle_tool_by_checkbox'));
add_action('wp_ajax_sflmcp_bulk_toggle_tools_by_id', array($this, 'ajax_bulk_toggle_tools_by_id'));
// AJAX handlers for WordPress Abilities API (WordPress 6.9+)
add_action('wp_ajax_sflmcp_discover_abilities', array($this, 'ajax_discover_abilities'));
add_action('wp_ajax_sflmcp_import_ability', array($this, 'ajax_import_ability'));
add_action('wp_ajax_sflmcp_toggle_ability', array($this, 'ajax_toggle_ability'));
add_action('wp_ajax_sflmcp_delete_ability', array($this, 'ajax_delete_ability'));
add_action('wp_ajax_sflmcp_get_imported_abilities', array($this, 'ajax_get_imported_abilities'));
add_action('wp_ajax_sflmcp_bulk_manage_abilities', array($this, 'ajax_bulk_manage_abilities'));
// AJAX handlers for Multimedia settings
add_action('wp_ajax_sflmcp_save_multimedia_settings', array($this, 'ajax_save_multimedia_settings'));
add_action('wp_ajax_sflmcp_load_multimedia_settings', array($this, 'ajax_load_multimedia_settings'));
add_action('wp_ajax_sflmcp_mm_toggle_tool', array($this, 'ajax_mm_toggle_tool'));
add_action('wp_ajax_sflmcp_mm_reveal_key', array($this, 'ajax_mm_reveal_key'));
// AJAX handlers for OAuth Clients
add_action('wp_ajax_sflmcp_oauth_delete_client', array($this, 'ajax_oauth_delete_client'));
add_action('wp_ajax_sflmcp_oauth_revoke_token', array($this, 'ajax_oauth_revoke_token'));
add_action('wp_ajax_sflmcp_oauth_reset_state', array($this, 'ajax_oauth_reset_state'));
add_action('wp_ajax_sflmcp_oauth_save_settings', array($this, 'ajax_oauth_save_settings'));
add_action('wp_ajax_sflmcp_generate_app_password', array($this, 'ajax_generate_app_password'));
}
}
private function ensureDispatcherCallbackRegistered(): void {
if ($this->addedFilter) {
return;
}
StifliFlexMcpDispatcher::addFilter('sflmcp_callback', array($this, 'handleCallback'), 10, 4);
$this->addedFilter = true;
}
public function restApiInit() {
register_rest_route($this->namespace, '/sse', array(
'methods' => 'GET',
'callback' => array($this, 'handleSSE'),
'permission_callback' => function( $request ) {
return $this->canAccessMCP($request);
},
));
register_rest_route($this->namespace, '/sse', array(
'methods' => 'POST',
'callback' => array($this, 'handleSSE'),
'permission_callback' => function( $request ) {
return $this->canAccessMCP($request);
},
));
register_rest_route($this->namespace, '/messages', array(
'methods' => 'POST',
'callback' => array($this, 'handleMessage'),
'permission_callback' => function( $request ) {
return $this->canAccessMCP($request);
},
));
register_rest_route($this->namespace, '/messages', array(
'methods' => 'GET',
'callback' => array($this, 'handleMessagesGet'),
'permission_callback' => function( $request ) {
return $this->canAccessMCP($request);
},
));
$this->ensureDispatcherCallbackRegistered();
}
/**
* Check if the request can access the MCP endpoint.
* WordPress 5.6+ handles Application Password authentication natively.
* This method only checks that the user has sufficient capabilities.
*
* @see https://developer.wordpress.org/rest-api/using-the-rest-api/authentication/
* @see https://make.wordpress.org/core/2020/11/05/application-passwords-integration-guide/
*/
public function canAccessMCP( $request ) {
// --- Verbose debug logging ---
$req_method = $request->get_method();
$req_route = $request->get_route();
$auth_hdr = $request->get_header( 'Authorization' );
stifli_flex_mcp_log( sprintf(
'canAccessMCP: %s %s | Auth: %s | User-Agent: %s',
$req_method,
$req_route,
$auth_hdr ? substr( $auth_hdr, 0, 20 ) . '...' : '(none)',
$request->get_header( 'User-Agent' ) ?: '(none)'
) );
// Streamable HTTP security: reject invalid Origin headers.
$origin = trim( (string) $request->get_header( 'Origin' ) );
if ( '' !== $origin ) {
$origin_host = wp_parse_url( $origin, PHP_URL_HOST );
$site_host = wp_parse_url( home_url(), PHP_URL_HOST );
$allowed_hosts = array_filter( apply_filters( 'sflmcp_allowed_origin_hosts', array( $site_host ) ) );
$allowed_hosts = array_map( static function( $host ) {
return strtolower( (string) $host );
}, $allowed_hosts );
$origin_host = strtolower( (string) $origin_host );
if ( '' === $origin_host || ! in_array( $origin_host, $allowed_hosts, true ) ) {
stifli_flex_mcp_log( sprintf( 'canAccessMCP: Invalid Origin header rejected (%s)', $origin ) );
return new WP_Error( 'invalid_origin', 'Invalid Origin header.', array( 'status' => 403 ) );
}
}
// --- Rate limiting: 30 requests/minute per IP ---
$ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '0.0.0.0';
$rate_key = 'sflmcp_rate_' . md5( $ip );
$rate_data = get_transient( $rate_key );
if ( false === $rate_data ) {
$rate_data = array( 'count' => 0, 'start' => time() );
}
$rate_data['count']++;
$window = 60; // seconds
$limit = 30; // max requests per window
if ( ( time() - $rate_data['start'] ) > $window ) {
// Window expired, reset.
$rate_data = array( 'count' => 1, 'start' => time() );
} elseif ( $rate_data['count'] > $limit ) {
stifli_flex_mcp_log( sprintf( 'canAccessMCP: Rate limit exceeded for IP %s (%d requests in %ds)', $ip, $rate_data['count'], time() - $rate_data['start'] ) );
return new WP_Error( 'rate_limit_exceeded', 'Rate limit exceeded. Max ' . $limit . ' requests per minute.', array( 'status' => 429 ) );
}
set_transient( $rate_key, $rate_data, $window );
// --- OAuth 2.1 Bearer token validation ---
$auth_header = $request->get_header( 'Authorization' );
if ( $auth_header && stripos( $auth_header, 'Bearer ' ) === 0 ) {
$bearer_token = substr( $auth_header, 7 );
if ( class_exists( 'StifliFlexMcp_OAuth_Server' ) ) {
$oauth_user_id = StifliFlexMcp_OAuth_Server::get_instance()->validate_token( $bearer_token );
if ( $oauth_user_id ) {
wp_set_current_user( $oauth_user_id );
stifli_flex_mcp_log( sprintf( 'canAccessMCP: OAuth token validated for user %d', $oauth_user_id ) );
// Resolve OAuth client name for source tracking
if ( class_exists( 'StifliFlexMcp_ChangeTracker' ) ) {
$client_label = $this->resolveOAuthClientLabel( $bearer_token );
StifliFlexMcp_ChangeTracker::setSourceContext( 'mcp', $client_label );
}
return true;
}
}
// Bearer token present but invalid → 401.
stifli_flex_mcp_log( 'canAccessMCP: Invalid OAuth Bearer token' );
return new WP_Error( 'invalid_token', 'Invalid or expired Bearer token.', array( 'status' => 401 ) );
}
$current_user_id = get_current_user_id();
if ($current_user_id > 0 && current_user_can('edit_posts')) {
stifli_flex_mcp_log(sprintf('canAccessMCP: user %d has sufficient capabilities', $current_user_id));
return true;
}
stifli_flex_mcp_log('canAccessMCP: Access denied - no authenticated user with edit_posts capability');
if ( class_exists( 'StifliFlexMcp_OAuth_Server' ) ) {
return StifliFlexMcp_OAuth_Server::get_instance()->get_unauthorized_error();
}
return false;
}
/**
* Resolve OAuth client_name from a bearer token for source tracking.
*
* @param string $bearer_token Raw bearer token.
* @return string Client name or empty string.
*/
private function resolveOAuthClientLabel( $bearer_token ) {
if ( ! class_exists( 'StifliFlexMcp_OAuth_Storage' ) ) {
return '';
}
$storage = StifliFlexMcp_OAuth_Storage::get_instance();
$record = $storage->validate_access_token( $bearer_token );
if ( $record && ! empty( $record->client_id ) ) {
$client = $storage->get_client( $record->client_id );
if ( $client && ! empty( $client->client_name ) ) {
return $client->client_name;
}
}
return '';
}
public function handleCallback( $result, string $tool, array $args, $id ) {
if (!empty($result)) {
return $result;
}
$tools = $this->getModel()->getTools();
if (!isset($tools[$tool])) {
StifliFlexMcpFrame::_()->saveDebugLogging('Tool not found ' . $tool, false, 'SFLMCP');
return $result;
}
return $this->getModel()->dispatchTool($tool, $args, $id);
}
private function getSSEid($req) {
$last = $req ? $req->get_header('last-event-id') : '';
return empty($last) ? str_replace('-', '', wp_generate_uuid4()) : $last;
}
private function getSupportedProtocolVersions(): array {
return array('2025-11-25', '2025-06-18', '2025-03-26');
}
private function isSupportedProtocolVersion( $version ): bool {
if (!is_string($version) || '' === $version) {
return false;
}
return in_array($version, $this->getSupportedProtocolVersions(), true);
}
private function negotiateProtocolVersion( $requestedVersion ): string {
if ($this->isSupportedProtocolVersion($requestedVersion)) {
return $requestedVersion;
}
return $this->protocolVersion;
}
private function getProtocolVersionHeader( $request ) {
$version = trim((string) $request->get_header('MCP-Protocol-Version'));
return '' === $version ? null : $version;
}
private function protocolVersionErrorResponse( $id, string $requestedVersion ) {
$response = new WP_REST_Response(array(
'jsonrpc' => '2.0',
'id' => $id,
'error' => array(
'code' => -32602,
'message' => 'Unsupported protocol version',
'data' => array(
'supported' => $this->getSupportedProtocolVersions(),
'requested' => $requestedVersion,
),
),
), 400);
$response->set_headers(array('Content-Type' => 'application/json'));
return $this->withNoCacheHeaders($response);
}
private function getNoCacheHeaders(): array {
return array(
'Cache-Control' => 'no-store, no-cache, must-revalidate, private',
'Pragma' => 'no-cache',
'Expires' => '0',
);
}
private function withNoCacheHeaders( WP_REST_Response $response ): WP_REST_Response {
foreach ($this->getNoCacheHeaders() as $name => $value) {
$response->header($name, $value);
}
return $response;
}
public function addNoCacheHeadersForNamespace( $response, $server, $request ) {
if (!is_object($response) || !method_exists($response, 'header')) {
return $response;
}
$route = (is_object($request) && method_exists($request, 'get_route'))
? (string) $request->get_route()
: '';
if ('' === $route) {
return $response;
}
$prefix = '/' . $this->namespace;
if (0 !== strpos($route, $prefix)) {
return $response;
}
foreach ($this->getNoCacheHeaders() as $name => $value) {
$response->header($name, $value);
}
return $response;
}
private function withProtocolHeaders( WP_REST_Response $response, string $protocolVersion, bool $hasJsonBody = true ): WP_REST_Response {
$headers = array(
'MCP-Protocol-Version' => $protocolVersion,
);
$headers = array_merge($headers, $this->getNoCacheHeaders());
if ($hasJsonBody) {
$headers['Content-Type'] = 'application/json';
}
$response->set_headers($headers);
return $response;
}
private function getTaskManagedTools(): array {
return array('wp_generate_image', 'wp_generate_video');
}
private function toolRequiresTask( $tool ): bool {
return is_string($tool) && in_array($tool, $this->getTaskManagedTools(), true);
}
private function normalizeArgumentsForTaskKey( $value ) {
if (!is_array($value)) {
return $value;
}
$isList = array_keys($value) === range(0, count($value) - 1);
if (!$isList) {
ksort($value);
}
foreach ($value as $k => $v) {
$value[$k] = $this->normalizeArgumentsForTaskKey($v);
}
return $value;
}
private function getManagedFallbackTransientKey( int $userId, string $tool, array $arguments ): string {
$normalized = $this->normalizeArgumentsForTaskKey($arguments);
$encoded = wp_json_encode($normalized);
if (false === $encoded) {
$encoded = '';
}
return 'sflmcp_mft_' . md5($userId . '|' . $tool . '|' . $encoded);
}
private function buildManagedFallbackWorkingReply( $id, string $taskId, string $tool, string $status = 'working', string $message = '' ): array {
if ('' === $message) {
$message = 'Generating media in background. Please wait and repeat the same tool call to retrieve the result.';
}
return array(
'jsonrpc' => '2.0',
'id' => $id,
'result' => array(
'content' => array(
array(
'type' => 'text',
'text' => $message,
),
),
'structuredContent' => array(
'taskId' => $taskId,
'status' => $status,
'tool' => $tool,
),
'isError' => false,
),
);
}
private function withRequestIdFromPayload( array $payload, $id ): array {
if (isset($payload['error']) && is_array($payload['error'])) {
return array(
'jsonrpc' => '2.0',
'id' => $id,
'error' => $payload['error'],
);
}
if (isset($payload['result']) && is_array($payload['result'])) {
return array(
'jsonrpc' => '2.0',
'id' => $id,
'result' => $payload['result'],
);
}
return $this->rpcError($id, -32603, 'Task payload is invalid');
}
private function handleManagedToolWithoutTask( $id, string $tool, array $arguments, array $params, string $toolLog ) {
$userId = (int) get_current_user_id();
$fallbackKey = $this->getManagedFallbackTransientKey($userId, $tool, $arguments);
$existingTaskId = get_transient($fallbackKey);
if (is_string($existingTaskId) && '' !== $existingTaskId) {
$existingTask = $this->loadTaskRecord($existingTaskId, true);
if (is_array($existingTask)) {
$status = isset($existingTask['status']) ? (string) $existingTask['status'] : 'working';
if ($this->isTaskTerminalStatus($status)) {
stifli_flex_mcp_log('tools/call: returning completed fallback task result for tool=' . $toolLog . ' taskId=' . $existingTaskId);
delete_transient($fallbackKey);
$payload = isset($existingTask['payload']) && is_array($existingTask['payload']) ? $existingTask['payload'] : array();
return $this->withRequestIdFromPayload($payload, $id);
}
stifli_flex_mcp_log('tools/call: fallback task still working for tool=' . $toolLog . ' taskId=' . $existingTaskId);
return $this->buildManagedFallbackWorkingReply($id, $existingTaskId, $tool, $status);
}
delete_transient($fallbackKey);
}
$taskReply = $this->createToolTaskResult($id, $tool, $arguments, $params);
$taskId = isset($taskReply['result']['task']['taskId']) ? (string) $taskReply['result']['task']['taskId'] : '';
if ('' === $taskId) {
return $this->rpcError($id, -32603, 'Failed to create background task');
}
$taskTtlMs = isset($taskReply['result']['task']['ttl']) ? (int) $taskReply['result']['task']['ttl'] : (int) $this->taskDefaultTtlMs;
$taskTtlSeconds = max(60, (int) ceil($taskTtlMs / 1000) + 300);
set_transient($fallbackKey, $taskId, $taskTtlSeconds);
stifli_flex_mcp_log('tools/call: created fallback background task for tool=' . $toolLog . ' taskId=' . $taskId);
return $this->buildManagedFallbackWorkingReply($id, $taskId, $tool);
}
private function nowIso8601(): string {
return gmdate('c');
}
private function normalizeTaskTtlMs( $requestedTtl ): int {
if (!is_numeric($requestedTtl)) {
return (int) $this->taskDefaultTtlMs;
}
$ttl = (int) $requestedTtl;
if ($ttl <= 0) {
return (int) $this->taskDefaultTtlMs;
}
return min($ttl, (int) $this->taskMaxTtlMs);
}
private function getTaskTransientKey( string $taskId ): string {
return 'sflmcp_task_' . md5($taskId);
}
private function getTaskIndexTransientKey( int $userId ): string {
return 'sflmcp_task_index_' . $userId;
}
private function getTaskIndexForUser( int $userId ): array {
if ($userId <= 0) {
return array();
}
$index = get_transient($this->getTaskIndexTransientKey($userId));
if (!is_array($index)) {
return array();
}
return array_values(array_filter($index, 'is_string'));
}
private function saveTaskIndexForUser( int $userId, array $taskIds, int $ttlSeconds ): void {
if ($userId <= 0) {
return;
}
$taskIds = array_values(array_unique(array_filter($taskIds, 'is_string')));
if (count($taskIds) > 200) {
$taskIds = array_slice($taskIds, -200);
}
set_transient($this->getTaskIndexTransientKey($userId), $taskIds, max(60, $ttlSeconds));
}
private function addTaskToUserIndex( int $userId, string $taskId, int $ttlSeconds ): void {
$index = $this->getTaskIndexForUser($userId);
$index[] = $taskId;
$this->saveTaskIndexForUser($userId, $index, $ttlSeconds);
}
private function removeTaskFromUserIndex( int $userId, string $taskId ): void {
$index = $this->getTaskIndexForUser($userId);
if (empty($index)) {
return;
}
$index = array_values(array_filter($index, static function( $value ) use ( $taskId ) {
return $value !== $taskId;
}));
$this->saveTaskIndexForUser($userId, $index, 3600);
}
private function isTaskTerminalStatus( $status ): bool {
return in_array($status, array('completed', 'failed', 'cancelled'), true);
}
private function isTaskExpired( array $task ): bool {
if (!isset($task['ttl']) || is_null($task['ttl'])) {
return false;
}
$createdAt = isset($task['createdAt']) ? strtotime((string) $task['createdAt']) : false;
if (false === $createdAt) {
return false;
}
$expiresAtMs = ((int) $createdAt * 1000) + (int) $task['ttl'];
$nowMs = (int) round(microtime(true) * 1000);
return $nowMs > $expiresAtMs;
}
private function saveTaskRecord( array $task ): void {
if (empty($task['taskId'])) {
return;
}
$ttlMs = isset($task['ttl']) && !is_null($task['ttl']) ? (int) $task['ttl'] : (int) $this->taskDefaultTtlMs;
$ttlSeconds = max(60, (int) ceil($ttlMs / 1000) + 300);
set_transient($this->getTaskTransientKey($task['taskId']), $task, $ttlSeconds);
$ownerUserId = isset($task['ownerUserId']) ? (int) $task['ownerUserId'] : 0;
if ($ownerUserId > 0) {
$this->addTaskToUserIndex($ownerUserId, $task['taskId'], $ttlSeconds);
}
}
private function loadTaskRecord( string $taskId, bool $pruneExpired = true ) {
$task = get_transient($this->getTaskTransientKey($taskId));
if (!is_array($task)) {
return null;
}
if ($pruneExpired && $this->isTaskExpired($task)) {
$this->deleteTaskRecord($taskId, $task);
return null;
}
return $task;
}
private function deleteTaskRecord( string $taskId, $existingTask = null ): void {
$task = is_array($existingTask) ? $existingTask : $this->loadTaskRecord($taskId, false);
delete_transient($this->getTaskTransientKey($taskId));
if (is_array($task) && isset($task['ownerUserId'])) {
$this->removeTaskFromUserIndex((int) $task['ownerUserId'], $taskId);
}
}
private function formatTaskForResult( array $task ): array {
$result = array(
'taskId' => isset($task['taskId']) ? $task['taskId'] : '',
'status' => isset($task['status']) ? $task['status'] : 'working',
'createdAt' => isset($task['createdAt']) ? $task['createdAt'] : $this->nowIso8601(),
'lastUpdatedAt' => isset($task['lastUpdatedAt']) ? $task['lastUpdatedAt'] : $this->nowIso8601(),
'ttl' => isset($task['ttl']) ? $task['ttl'] : (int) $this->taskDefaultTtlMs,
'pollInterval' => isset($task['pollInterval']) ? (int) $task['pollInterval'] : (int) $this->taskPollIntervalMs,
);
if (!empty($task['statusMessage'])) {
$result['statusMessage'] = $task['statusMessage'];
}
return $result;
}
private function getScopedTaskRecord( string $taskId, $id, &$errorReply = null ) {
$task = $this->loadTaskRecord($taskId, true);
if (!is_array($task)) {
$errorReply = $this->rpcError($id, -32602, 'Failed to retrieve task: Task not found');
return null;
}
$currentUserId = get_current_user_id();
$ownerUserId = isset($task['ownerUserId']) ? (int) $task['ownerUserId'] : 0;
if ($ownerUserId > 0 && $currentUserId > 0 && $ownerUserId !== $currentUserId) {
$errorReply = $this->rpcError($id, -32602, 'Failed to retrieve task: Task not found');
return null;
}
return $task;
}
private function createToolTaskResult( $id, string $tool, array $arguments, array $params ): array {
$taskParams = isset($params['task']) && is_array($params['task']) ? $params['task'] : array();
$ttlMs = $this->normalizeTaskTtlMs(isset($taskParams['ttl']) ? $taskParams['ttl'] : null);
$taskId = wp_generate_uuid4();
$now = $this->nowIso8601();
$task = array(
'taskId' => $taskId,
'status' => 'working',
'statusMessage' => 'The operation is now in progress.',
'createdAt' => $now,
'lastUpdatedAt' => $now,
'ttl' => $ttlMs,
'pollInterval' => (int) $this->taskPollIntervalMs,
'ownerUserId' => (int) get_current_user_id(),
'toolName' => $tool,
'toolArguments' => $arguments,
'requestId' => $id,
'payload' => null,
);
$this->saveTaskRecord($task);
if (!wp_next_scheduled('sflmcp_process_task', array($taskId))) {
wp_schedule_single_event(time() + 1, 'sflmcp_process_task', array($taskId));
if (function_exists('spawn_cron')) {
spawn_cron(time());
}
}
return array(
'jsonrpc' => '2.0',
'id' => $id,
'result' => array(
'task' => $this->formatTaskForResult($task),
'_meta' => array(
'io.modelcontextprotocol/model-immediate-response' => 'Generating media. Please wait and poll the task status.',
),
),
);
}
public function processTaskAsync( $taskId ): void {
$taskId = is_scalar($taskId) ? (string) $taskId : '';
if ('' === $taskId) {
return;
}
$task = $this->loadTaskRecord($taskId, true);
if (!is_array($task)) {
return;
}
if ($this->isTaskTerminalStatus(isset($task['status']) ? $task['status'] : '')) {
return;
}
stifli_flex_mcp_log('task/process: start taskId=' . $taskId . ' tool=' . (isset($task['toolName']) ? $task['toolName'] : 'n/a'));
if ( function_exists( 'ignore_user_abort' ) ) {
ignore_user_abort(true);
}
// phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged,WordPress.PHP.NoSilencedErrors.Discouraged -- required to avoid execution cut during long-running task processing.
@set_time_limit(0);
$originalUserId = get_current_user_id();
$ownerUserId = isset($task['ownerUserId']) ? (int) $task['ownerUserId'] : 0;
if ($ownerUserId > 0 && $ownerUserId !== $originalUserId) {
wp_set_current_user($ownerUserId);
}
$this->ensureDispatcherCallbackRegistered();
try {
$toolName = isset($task['toolName']) ? (string) $task['toolName'] : '';
$toolArgs = isset($task['toolArguments']) && is_array($task['toolArguments']) ? $task['toolArguments'] : array();
$requestId = isset($task['requestId']) ? $task['requestId'] : null;
$payload = $this->executeTool($toolName, $toolArgs, $requestId);
$status = 'completed';
$statusMessage = 'Task completed successfully.';
if (is_array($payload) && isset($payload['error'])) {
$status = 'failed';
$errorMessage = isset($payload['error']['message']) ? (string) $payload['error']['message'] : 'Unknown error';
$statusMessage = 'Task failed: ' . $errorMessage;
} elseif (is_array($payload) && isset($payload['result']['isError']) && true === $payload['result']['isError']) {
$status = 'failed';
$statusMessage = 'Task failed during tool execution.';
}
$task['payload'] = $payload;
$task['status'] = $status;
$task['statusMessage'] = $statusMessage;
$task['lastUpdatedAt'] = $this->nowIso8601();
$this->saveTaskRecord($task);
stifli_flex_mcp_log('task/process: done taskId=' . $taskId . ' status=' . $status);
} catch ( Throwable $e ) {
$task['payload'] = $this->rpcError(isset($task['requestId']) ? $task['requestId'] : null, -32603, 'Internal task error', $e->getMessage());
$task['status'] = 'failed';
$task['statusMessage'] = 'Task failed: ' . $e->getMessage();
$task['lastUpdatedAt'] = $this->nowIso8601();
$this->saveTaskRecord($task);
stifli_flex_mcp_log('task/process: failed taskId=' . $taskId . ' message=' . $e->getMessage());
}
if ($ownerUserId !== $originalUserId) {
wp_set_current_user($originalUserId);
}
}
private function handleTasksMethod( string $method, array $params, $id ) {
$taskId = isset($params['taskId']) && is_scalar($params['taskId']) ? trim((string) $params['taskId']) : '';
switch ($method) {
case 'tasks/get':
if ('' === $taskId) {
return $this->rpcError($id, -32602, 'Failed to retrieve task: taskId is required');
}
$errorReply = null;
$task = $this->getScopedTaskRecord($taskId, $id, $errorReply);
if (!is_array($task)) {
return $errorReply;
}
return array('jsonrpc' => '2.0', 'id' => $id, 'result' => $this->formatTaskForResult($task));
case 'tasks/result':
if ('' === $taskId) {
return $this->rpcError($id, -32602, 'Failed to retrieve task: taskId is required');
}
$errorReply = null;
$task = $this->getScopedTaskRecord($taskId, $id, $errorReply);
if (!is_array($task)) {
return $errorReply;
}
if (!$this->isTaskTerminalStatus(isset($task['status']) ? $task['status'] : '')) {
$this->processTaskAsync($taskId);
$task = $this->loadTaskRecord($taskId, true);
}
if (!is_array($task)) {
return $this->rpcError($id, -32602, 'Failed to retrieve task: Task not found');
}
if (!$this->isTaskTerminalStatus(isset($task['status']) ? $task['status'] : '')) {
return $this->rpcError($id, -32603, 'Task is still in progress');
}
$payload = isset($task['payload']) ? $task['payload'] : null;
if (!is_array($payload)) {
return $this->rpcError($id, -32603, 'Task has no result payload');
}
if (isset($payload['error']) && is_array($payload['error'])) {
return array('jsonrpc' => '2.0', 'id' => $id, 'error' => $payload['error']);
}
if (!isset($payload['result']) || !is_array($payload['result'])) {
return $this->rpcError($id, -32603, 'Task payload is invalid');
}
$result = $payload['result'];
if (!isset($result['_meta']) || !is_array($result['_meta'])) {
$result['_meta'] = array();
}
$result['_meta']['io.modelcontextprotocol/related-task'] = array('taskId' => $taskId);
return array('jsonrpc' => '2.0', 'id' => $id, 'result' => $result);
case 'tasks/cancel':
if ('' === $taskId) {
return $this->rpcError($id, -32602, 'Cannot cancel task: taskId is required');
}
$errorReply = null;
$task = $this->getScopedTaskRecord($taskId, $id, $errorReply);
if (!is_array($task)) {
return $errorReply;
}
if ($this->isTaskTerminalStatus(isset($task['status']) ? $task['status'] : '')) {
return $this->rpcError($id, -32602, "Cannot cancel task: already in terminal status '" . $task['status'] . "'");
}
$task['status'] = 'cancelled';
$task['statusMessage'] = 'The task was cancelled by request.';
$task['lastUpdatedAt'] = $this->nowIso8601();
$this->saveTaskRecord($task);
return array('jsonrpc' => '2.0', 'id' => $id, 'result' => $this->formatTaskForResult($task));
case 'tasks/list':
$cursor = isset($params['cursor']) ? (string) $params['cursor'] : '';
if ('' !== $cursor && !ctype_digit($cursor)) {
return $this->rpcError($id, -32602, 'Invalid cursor');
}
$offset = '' === $cursor ? 0 : (int) $cursor;
$limit = 20;
$userId = (int) get_current_user_id();
$index = $this->getTaskIndexForUser($userId);
$tasks = array();
$validTaskIds = array();
foreach ($index as $taskIdFromIndex) {
$task = $this->loadTaskRecord($taskIdFromIndex, true);
if (!is_array($task)) {
continue;
}
$validTaskIds[] = $taskIdFromIndex;
$tasks[] = $task;
}
$this->saveTaskIndexForUser($userId, $validTaskIds, 3600);
usort($tasks, static function( $a, $b ) {
$ta = isset($a['createdAt']) ? strtotime((string) $a['createdAt']) : 0;
$tb = isset($b['createdAt']) ? strtotime((string) $b['createdAt']) : 0;
return $tb <=> $ta;
});
$total = count($tasks);
$slice = array_slice($tasks, $offset, $limit);
$result = array(
'tasks' => array_map(array($this, 'formatTaskForResult'), $slice),
);
if (($offset + $limit) < $total) {
$result['nextCursor'] = (string) ($offset + $limit);
}
return array('jsonrpc' => '2.0', 'id' => $id, 'result' => $result);
default:
return $this->rpcError($id, -32601, 'Method not found: ' . $method);
}
}
private function handleToolsCallMethod( $id, array $params ) {
$tool = null;
$arguments = array();
if (isset($params['name'])) {
$tool = $params['name'];
$arguments = isset($params['arguments']) && is_array($params['arguments']) ? $params['arguments'] : array();
} elseif (isset($params['tool'])) {
$tool = $params['tool'];
$arguments = isset($params['args']) && is_array($params['args']) ? $params['args'] : array();
}
$isTaskAugmented = isset($params['task']) && is_array($params['task']);
$toolLog = wp_json_encode($tool);
if (false === $toolLog) {
$toolLog = is_scalar($tool) ? (string) $tool : '[unserializable]';
}
$argsLog = wp_json_encode($arguments);
if (false === $argsLog) {
$argsLog = '[unserializable]';
}
stifli_flex_mcp_log(sprintf('tools/call: tool=%s arguments=%s task_augmented=%s', $toolLog, $argsLog, $isTaskAugmented ? 'yes' : 'no'));
if ($this->toolRequiresTask($tool)) {
if ($isTaskAugmented) {
stifli_flex_mcp_log('tools/call: creating async task for tool=' . $toolLog);
return $this->createToolTaskResult($id, $tool, $arguments, $params);
}
// Compatibility fallback for clients that still don't support task augmentation.
stifli_flex_mcp_log('tools/call: task missing for managed tool, using async compatibility fallback for tool=' . $toolLog);
return $this->handleManagedToolWithoutTask($id, $tool, $arguments, $params, $toolLog);
}
if ($isTaskAugmented) {
stifli_flex_mcp_log('tools/call: task augmentation rejected for unsupported tool=' . $toolLog);
return $this->rpcError($id, -32601, 'Task augmentation is not supported for tool: ' . $tool);
}
return $this->executeTool($tool, $arguments, $id);
}
public function handleSSE( $request ) {
$body = $request->get_body();
$remote = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : 'n/a';
$uaHeader = $request->get_header('User-Agent');
$ua = $uaHeader ? sanitize_text_field( $uaHeader ) : 'n/a';
$hdrAuth = $request->get_header('Authorization') ? 'present' : 'none';
$qp = $request->get_param('token') ? 'present' : 'none';
stifli_flex_mcp_log(sprintf('handleSSE start: remote=%s, method=%s, auth_header=%s, query_token=%s, body_len=%d, ua=%s', $remote, $request->get_method(), $hdrAuth, $qp, strlen($body), $ua));
if ($request->get_method() === 'POST' && !empty($body)) {
$data = json_decode($body, true);
if ($data && isset($data['method'])) {
return $this->handleDirectJsonRPC($request, $data);
}
}
if ( function_exists( 'ini_set' ) ) {
ini_set('zlib.output_compression', '0'); // phpcs:ignore WordPress.PHP.DiscouragedFunctions.runtime_ini_set,Squiz.PHP.DiscouragedFunctions.Discouraged
ini_set('output_buffering', '0'); // phpcs:ignore WordPress.PHP.DiscouragedFunctions.runtime_ini_set,Squiz.PHP.DiscouragedFunctions.Discouraged
ini_set('implicit_flush', '1'); // phpcs:ignore WordPress.PHP.DiscouragedFunctions.runtime_ini_set,Squiz.PHP.DiscouragedFunctions.Discouraged
}
if (function_exists('ob_implicit_flush')) {
ob_implicit_flush( true );
}
header('Content-Type: text/event-stream');
header('Cache-Control: no-store, no-cache, must-revalidate, private');
header('Pragma: no-cache');
header('Expires: 0');
header('X-Accel-Buffering: no');
header('Connection: keep-alive');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Headers: Authorization, Content-Type');
while (ob_get_level()) {
ob_end_flush();
}
$this->sessionID = $this->getSSEid($request);
$this->lastAction = time();
$msgUri = sprintf('%s/messages?session_id=%s', rest_url($this->namespace), $this->sessionID);
// Note: Client must include HTTP Basic auth header when posting to msgUri
stifli_flex_mcp_log('handleSSE: sessionID=' . $this->sessionID . ' msgUri=' . $msgUri);
$this->reply('endpoint', $msgUri, 'text');
while (true) {
$maxTime = $this->logging ? 60 : 60 * 5;
$idle = ( time() - $this->lastAction ) >= $maxTime;
if (connection_aborted() || $idle) {
stifli_flex_mcp_log('handleSSE: connection aborted or idle, aborting session ' . $this->sessionID);
$this->reply('bye');
break;
}
foreach ($this->fetchMessages($this->sessionID) as $p) {
if (isset($p['method']) && 'SFLMCP/kill' === $p['method']) {
$this->reply('bye');
exit;
}
stifli_flex_mcp_log('handleSSE: sending message to session ' . $this->sessionID . ' method=' . (isset($p['method']) ? $p['method'] : 'n/a'));
$this->reply('message', $p);
}
usleep(200000);
if (time() - $this->lastAction > 10) $this->reply('heartbeat', ['status' => 'alive']);
}
exit;
}
private function reply( string $event, $data = null, string $enc = 'json' ) {
if ('bye' === $event) {
echo "event: bye\ndata: \n\n";
if (ob_get_level()) {
ob_end_flush();
}
flush();
$this->lastAction = time();
return;
}
if ('json' === $enc && null === $data) {
return;
}
echo 'event: ' . esc_attr( $event ) . "\n";
if ('json' === $enc) {
// SSE data is consumed by MCP clients (not HTML context).
// wp_json_encode handles safe encoding; esc_html would break JSON
// by converting " to ". phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
$data = null === $data ? '{}' : str_replace('[]', '{}', wp_json_encode($data, JSON_UNESCAPED_UNICODE));
}
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- SSE text/event-stream data, not HTML context
echo 'data: ' . $data . "\n\n";
if (ob_get_level()) {
ob_end_flush();
}
flush();
$this->lastAction = time();
}
public function handleDirectJsonRPC( $request, $data ) {
$id = isset($data['id']) ? $data['id'] : null;
$method = isset($data['method']) ? $data['method'] : null;
$replyProtocolVersion = $this->protocolVersion;
$qp = $request->get_param('token') ? 'present' : 'none';
$hdr = $request->get_header('Authorization') ? 'present' : 'none';
stifli_flex_mcp_log(sprintf('handleDirectJsonRPC: id=%s method=%s header=%s query=%s', $id, $method, $hdr, $qp));
// Set session_id for ChangeTracker — use query param or generate per request.
if ( class_exists( 'StifliFlexMcp_ChangeTracker' ) ) {
$sess = sanitize_text_field( $request->get_param( 'session_id' ) );
if ( ! $sess ) {
$sess = 'mcp-' . wp_generate_uuid4();
}
StifliFlexMcp_ChangeTracker::getInstance()->setSessionId( $sess );
}
if (json_last_error() !== JSON_ERROR_NONE) {
return new WP_REST_Response(array(
'jsonrpc' => '2.0',
'id' => null,
'error' => array('code' => -32700, 'message' => 'Parse error: invalid JSON'),
), 200);
}
if (!is_array($data) || !$method) {
return new WP_REST_Response(array(
'jsonrpc' => '2.0',
'id' => $id,
'error' => array('code' => -32600, 'message' => 'Invalid Request'),
), 200);
}
$headerProtocolVersion = $this->getProtocolVersionHeader($request);
if ('initialize' !== $method && null !== $headerProtocolVersion) {
if (!$this->isSupportedProtocolVersion($headerProtocolVersion)) {
return $this->protocolVersionErrorResponse($id, $headerProtocolVersion);
}
$replyProtocolVersion = $headerProtocolVersion;
}
try {
$reply = null;
switch ($method) {
case 'initialize':
$params = StifliFlexMcpUtils::getArrayValue($data, 'params', array(), 2);
$reqVersion = StifliFlexMcpUtils::getArrayValue($params, 'protocolVersion', null);
$replyProtocolVersion = $this->negotiateProtocolVersion($reqVersion);
$clientInfo = StifliFlexMcpUtils::getArrayValue($params, 'clientInfo', false);
// Store MCP client name for source tracking
if ( $clientInfo && class_exists( 'StifliFlexMcp_ChangeTracker' ) ) {
$client_name = is_array( $clientInfo ) && ! empty( $clientInfo['name'] ) ? $clientInfo['name'] : '';
if ( $client_name ) {
StifliFlexMcp_ChangeTracker::setSourceContext( 'mcp', $client_name );
}
}
$reply = array(
'jsonrpc' => '2.0',
'id' => $id,
'result' => array(
'protocolVersion' => $replyProtocolVersion,
'serverInfo' => (object) array(
'name' => get_bloginfo('name') . ' StifliFlexMcp',
'version' => $this->serverVersion,
),
'capabilities' => array(
'tools' => array('listChanged' => true),
'prompts' => array('subscribe' => false, 'listChanged' => false),
'resources' => array('subscribe' => false, 'listChanged' => false),
'tasks' => array(
'list' => (object) array(),
'cancel' => (object) array(),
'requests' => array(
'tools' => array(
'call' => (object) array(),
),
),
),
),
),
);
break;
case 'ping':
$reply = array(
'jsonrpc' => '2.0',
'id' => $id,
'result' => (object) array(),
);
break;
case 'tools/list':
$tools = $this->getToolsList();
$reply = array(
'jsonrpc' => '2.0',
'id' => $id,
'result' => array('tools' => $tools),
);
break;
case 'tools/call':
$params = StifliFlexMcpUtils::getArrayValue($data, 'params', array(), 2);
$reply = $this->handleToolsCallMethod($id, $params);
break;
case 'tasks/get':
case 'tasks/result':
case 'tasks/list':
case 'tasks/cancel':
$params = StifliFlexMcpUtils::getArrayValue($data, 'params', array(), 2);
$reply = $this->handleTasksMethod($method, $params, $id);
break;
case 'initialized':
case 'notifications/initialized':
return $this->withProtocolHeaders(new WP_REST_Response(null, 202), $replyProtocolVersion, false);
case 'resources/list':
$reply = array(
'jsonrpc' => '2.0',
'id' => $id,
'result' => array('resources' => array()),
);
break;
case 'prompts/list':
$reply = array(
'jsonrpc' => '2.0',
'id' => $id,
'result' => array('prompts' => array()),
);
break;
default:
if (is_null($id) && strpos($method, 'notifications/') === 0) {
return $this->withProtocolHeaders(new WP_REST_Response(null, 202), $replyProtocolVersion, false);
}
$reply = array(
'jsonrpc' => '2.0',
'id' => $id,
'error' => array('code' => -44001, 'message' => "Method not found: {$method}"),
);
}
return $this->withProtocolHeaders(new WP_REST_Response($reply, 200), $replyProtocolVersion);
}
catch ( Exception $e ) {
return $this->withProtocolHeaders(new WP_REST_Response(array(
'jsonrpc' => '2.0',
'id' => $id,
'error' => array('code' => -44000, 'message' => 'Internal error', 'data' => $e->getMessage())
), 200), $replyProtocolVersion);
}
}
public function handleMessagesGet( $request ) {
$response = new WP_REST_Response(null, 405);
$response->set_headers(array_merge($this->getNoCacheHeaders(), array(
'Allow' => 'POST',
'MCP-Protocol-Version' => $this->protocolVersion,
)));
return $response;
}
public function handleMessage( $request ) {
$sess = sanitize_text_field($request->get_param('session_id'));
$body = $request->get_body();
$hdr = $request->get_header('Authorization') ? 'present' : 'none';
$qp = $request->get_param('token') ? 'present' : 'none';
$remote = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : 'n/a';
stifli_flex_mcp_log(sprintf('handleMessage: session=%s remote=%s header=%s query=%s body_len=%d', $sess, $remote, $hdr, $qp, strlen($body)));
stifli_flex_mcp_log('handleMessage: RAW BODY: ' . $body);
$data = json_decode($body, true);
$decodedForLog = wp_json_encode($data);
if (false === $decodedForLog) {
$decodedForLog = '[unserializable]';
}
stifli_flex_mcp_log('handleMessage: JSON decoded: ' . $decodedForLog);
$id = isset($data['id']) ? $data['id'] : null;
$method = StifliFlexMcpUtils::getArrayValue($data, 'method', null);
$replyProtocolVersion = $this->protocolVersion;
$headerProtocolVersion = $this->getProtocolVersionHeader($request);
if ('initialize' !== $method && null !== $headerProtocolVersion) {
if (!$this->isSupportedProtocolVersion($headerProtocolVersion)) {
return $this->protocolVersionErrorResponse($id, $headerProtocolVersion);
}
$replyProtocolVersion = $headerProtocolVersion;
}
if ('initialized' === $method || 'notifications/initialized' === $method) {
return $this->withProtocolHeaders(new WP_REST_Response(null, 202), $replyProtocolVersion, false);
}
if ('SFLMCP/kill' === $method) {
$this->storeMessage($sess, array('jsonrpc' => '2.0', 'method' => 'SFLMCP/kill'));
usleep( 100000 );
return $this->withProtocolHeaders(new WP_REST_Response(null, 202), $replyProtocolVersion, false);
}
if (is_null($id) && !is_null($method)) {
return $this->withProtocolHeaders(new WP_REST_Response(null, 202), $replyProtocolVersion, false);
}
if (!$method) {
$hasRpcResponseShape = is_array($data)
&& array_key_exists('id', $data)
&& (array_key_exists('result', $data) || array_key_exists('error', $data));
if ($hasRpcResponseShape) {
return $this->withProtocolHeaders(new WP_REST_Response(null, 202), $replyProtocolVersion, false);
}
$this->queueError($sess, $id, -32900, 'Invalid Request: method missing');
return $this->withProtocolHeaders(new WP_REST_Response(null, 202), $replyProtocolVersion, false);
}
// Pass session_id to ChangeTracker for grouping
if ( class_exists( 'StifliFlexMcp_ChangeTracker' ) ) {
if ( ! $sess ) {
$sess = 'sse-' . wp_generate_uuid4();
}
StifliFlexMcp_ChangeTracker::getInstance()->setSessionId( $sess );
}
try {
$reply = null;
switch ($method) {
case 'initialize':
$params = StifliFlexMcpUtils::getArrayValue($data, 'params', array(), 2);
$requestedVersion = StifliFlexMcpUtils::getArrayValue($params, 'protocolVersion', null);
$replyProtocolVersion = $this->negotiateProtocolVersion($requestedVersion);
$clientInfo = StifliFlexMcpUtils::getArrayValue($params, 'clientInfo', null);
// Store MCP client name for source tracking (e.g. "Claude", "ChatGPT")
if ( $clientInfo && class_exists( 'StifliFlexMcp_ChangeTracker' ) ) {
$client_name = is_array( $clientInfo ) && ! empty( $clientInfo['name'] ) ? $clientInfo['name'] : '';
if ( $client_name ) {
StifliFlexMcp_ChangeTracker::setSourceContext( 'mcp', $client_name );
}
}
$reply = array(
'jsonrpc' => '2.0',
'id' => $id,
'result' => array(
'protocolVersion' => $replyProtocolVersion,
'serverInfo' => (object) array(
'name' => get_bloginfo( 'name' ) . ' StifliFlexMcp',
'version' => $this->serverVersion,
),
'capabilities' => array(
'tools' => array('listChanged' => true),
'prompts' => array('subscribe' => false, 'listChanged' => false),
'resources' => array('subscribe' => false, 'listChanged' => false),
'tasks' => array(
'list' => (object) array(),
'cancel' => (object) array(),
'requests' => array(
'tools' => array(
'call' => (object) array(),
),
),
),
),
),
);
break;
case 'ping':
$reply = array(
'jsonrpc' => '2.0',
'id' => $id,
'result' => (object) array(),
);
break;
case 'tools/list':
$tools = $this->getToolsList();
$reply = array(
'jsonrpc' => '2.0',
'id' => $id,
'result' => array('tools' => $tools),
);
break;
case 'resources/list':
$reply = array(
'jsonrpc' => '2.0',
'id' => $id,
'result' => array('resources' => $this->getResourcesList()),
);
break;
case 'prompts/list':
$reply = array(
'jsonrpc' => '2.0',
'id' => $id,
'result' => array('prompts' => $this->getPromptsList()),
);
break;
case 'tools/call':
$params = StifliFlexMcpUtils::getArrayValue($data, 'params', array(), 2);
$reply = $this->handleToolsCallMethod($id, $params);
break;
case 'tasks/get':
case 'tasks/result':
case 'tasks/list':
case 'tasks/cancel':
$params = StifliFlexMcpUtils::getArrayValue($data, 'params', array(), 2);
$reply = $this->handleTasksMethod($method, $params, $id);
break;
default:
$reply = $this->rpcError($id, -45601, "Method not found: {$method}");
}
if ($reply) {
// Devolver la respuesta JSON-RPC directamente
return $this->withProtocolHeaders(new WP_REST_Response($reply, 200), $replyProtocolVersion);
}
}
catch ( Exception $e ) {
$error = $this->rpcError($id, -45603, 'Internal error', $e->getMessage() );
return $this->withProtocolHeaders(new WP_REST_Response($error, 200), $replyProtocolVersion);
}
return $this->withProtocolHeaders(new WP_REST_Response(null, 202), $replyProtocolVersion, false);
}
public function getToolsList() {
$model = new StifliFlexMcpModel();
return $model->getToolsList();
}
private function getResourcesList() {
return array();
}
private function getPromptsList() {
return array();
}
private function executeTool( $tool, $args, $id ) {
try {
$toolLog = wp_json_encode($tool);
if (false === $toolLog) {
$toolLog = is_scalar($tool) ? (string) $tool : '[unserializable]';
}
$argsLog = wp_json_encode($args);
if (false === $argsLog) {
$argsLog = '[unserializable]';
}
$idLog = wp_json_encode($id);
if (false === $idLog) {
$idLog = is_scalar($id) ? (string) $id : '[unserializable]';
}
stifli_flex_mcp_log(sprintf('executeTool: tool=%s args=%s id=%s', $toolLog, $argsLog, $idLog));
$filtered = StifliFlexMcpDispatcher::applyFilters('sflmcp_callback', null, $tool, $args, $id, $this);
if (!is_null($filtered)) {
if (is_array($filtered) && isset($filtered['jsonrpc']) && isset($filtered['id'])) {
return $filtered;
}
return array(
'jsonrpc' => '2.0',
'id' => $id,
'result' => $filtered,
);
}
throw new Exception("Unknown tool: {$tool}");
}
catch ( Exception $e ) {
stifli_flex_mcp_log('executeTool: Exception: ' . $e->getMessage());
return $this->rpcError( $id, -44003, $e->getMessage() );
}
}
private function rpcError( $id, int $code, string $msg, $extra = null ): array {
$err = array('code' => $code, 'message' => $msg);
if (!is_null($extra)) {
$err['data'] = $extra;
}
return array('jsonrpc' => '2.0', 'id' => $id, 'error' => $err);
}
private function queueError( $sess, $id, int $code, string $msg, $extra = null ): void {
$this->storeMessage($sess, $this->rpcError($id, $code, $msg, $extra));
}
/*
* Custom queue and profile storage relies on plugin-managed tables.
* phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,PluginCheck.Security.DirectDB.UnescapedDBParameter
*/
private function storeMessage( $sess, $payload ) {
if (empty($sess) || empty($this->queueTable)) {
return;
}
global $wpdb;
$sessionKey = $this->normalizeSessionId($sess);
if ('' === $sessionKey) {
return;
}
$messageId = null;
if (is_array($payload) && array_key_exists('id', $payload)) {
if (is_null($payload['id'])) {
$messageId = null;
} elseif (is_scalar($payload['id'])) {
$messageId = (string) $payload['id'];
} else {
$messageId = wp_json_encode($payload['id']);
}
}
if (!is_null($messageId)) {
$messageId = substr($messageId, 0, 191);
}
$nowTs = current_time('timestamp', true);
$now = gmdate('Y-m-d H:i:s', $nowTs);
$expires = gmdate('Y-m-d H:i:s', $nowTs + $this->queueTtl);
$wpdb->insert(
$this->queueTable,
array(
'session_id' => $sessionKey,
'message_id' => $messageId,
'payload' => maybe_serialize($payload),
'created_at' => $now,
'expires_at' => $expires,
),
array('%s', '%s', '%s', '%s', '%s')
);
}
private function fetchMessages( $sess ) {
if (empty($sess) || empty($this->queueTable)) {
return array();
}
global $wpdb;
$sessionKey = $this->normalizeSessionId($sess);
if ('' === $sessionKey) {
return array();
}
$now = gmdate('Y-m-d H:i:s');
$queue_tbl = StifliFlexMcpUtils::getPrefixedTable('sflmcp_queue');
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table name from sanitized helper.
$rows = $wpdb->get_results(
$wpdb->prepare( "SELECT id, payload FROM {$queue_tbl} WHERE session_id = %s AND expires_at >= %s ORDER BY id ASC", $sessionKey, $now ),
ARRAY_A
);
if (empty($rows)) {
return array();
}
$ids = array();
$msgs = array();
foreach ($rows as $row) {
$ids[] = (int) $row['id'];
$decoded = maybe_unserialize($row['payload']);
if ($decoded === false && 'b:0;' !== $row['payload']) {
$decoded = $row['payload'];
}
$msgs[] = $decoded;
}
if (!empty($ids)) {
foreach ($ids as $deleteId) {
$wpdb->delete($this->queueTable, array('id' => $deleteId), array('%d'));
}
}
return $msgs;
}
private function normalizeSessionId( $sess ) {
if (empty($sess)) {
return '';
}
$sess = preg_replace('/[^A-Za-z0-9_\-]/', '', (string) $sess);
return substr($sess, 0, 191);
}
private function getModel() {
return new StifliFlexMcpModel();
}
// ============ PROFILE MANAGEMENT AJAX HANDLERS ============
public function ajax_apply_profile() {
if (!current_user_can('manage_options')) {
wp_send_json_error(array('message' => 'No permission'), 403);
}
check_ajax_referer('sflmcp_profiles');
global $wpdb;
$profile_id = isset($_POST['profile_id']) ? absint( wp_unslash( $_POST['profile_id'] ) ) : 0;
if ($profile_id <= 0) {
wp_send_json_error(array('message' => 'Invalid profile ID'));
}
$profiles_table = StifliFlexMcpUtils::getPrefixedTable('sflmcp_profiles', false);
$profile_tools_table = StifliFlexMcpUtils::getPrefixedTable('sflmcp_profile_tools', false);
$tools_table = StifliFlexMcpUtils::getPrefixedTable('sflmcp_tools', false);
$profiles_table_sql = StifliFlexMcpUtils::wrapTableNameForQuery($profiles_table);
$profile_tools_table_sql = StifliFlexMcpUtils::wrapTableNameForQuery($profile_tools_table);
$tools_table_sql = StifliFlexMcpUtils::wrapTableNameForQuery($tools_table);
$profiles_table_sql = StifliFlexMcpUtils::wrapTableNameForQuery($profiles_table);
$profile_tools_table_sql = StifliFlexMcpUtils::wrapTableNameForQuery($profile_tools_table);
$tools_table_sql = StifliFlexMcpUtils::wrapTableNameForQuery($tools_table);
$profiles_table_sql = StifliFlexMcpUtils::wrapTableNameForQuery($profiles_table);
$profile_tools_table_sql = StifliFlexMcpUtils::wrapTableNameForQuery($profile_tools_table);
$tools_table_sql = StifliFlexMcpUtils::wrapTableNameForQuery($tools_table);
$profiles_table_sql = StifliFlexMcpUtils::getPrefixedTable('sflmcp_profiles');
$profile_tools_table_sql = StifliFlexMcpUtils::getPrefixedTable('sflmcp_profile_tools');
$tools_table_sql = StifliFlexMcpUtils::getPrefixedTable('sflmcp_tools');
// Get profile tools
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table name from sanitized helper.
$profile_tools = $wpdb->get_col(
$wpdb->prepare( "SELECT tool_name FROM {$profile_tools_table_sql} WHERE profile_id = %d", $profile_id )
);
if ($profile_tools === null) {
wp_send_json_error(array('message' => 'Profile not found'));
}
// Disable all tools first
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table name from sanitized helper.
$wpdb->query($wpdb->prepare( "UPDATE {$tools_table_sql} SET enabled = %d", 0 ));
// Enable profile tools
if (!empty($profile_tools)) {
$placeholders = implode(',', array_fill(0, count($profile_tools), '%s'));
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table name from sanitized helper, placeholders are dynamic.
$wpdb->query(
$wpdb->prepare(
"UPDATE {$tools_table_sql} SET enabled = 1 WHERE tool_name IN ({$placeholders})",
...$profile_tools
)
);
}
// Mark profile as active
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table name from sanitized helper.
$wpdb->query($wpdb->prepare( "UPDATE {$profiles_table_sql} SET is_active = %d", 0 ));
$wpdb->update($profiles_table, array('is_active' => 1), array('id' => $profile_id), array('%d'), array('%d'));
wp_send_json_success(array('message' => count($profile_tools) . ' herramientas habilitadas'));
}
public function ajax_delete_profile() {
if (!current_user_can('manage_options')) {
wp_send_json_error(array('message' => 'No permission'), 403);
}
check_ajax_referer('sflmcp_profiles');
global $wpdb;
$profile_id = isset($_POST['profile_id']) ? absint( wp_unslash( $_POST['profile_id'] ) ) : 0;
$profiles_table = StifliFlexMcpUtils::getPrefixedTable('sflmcp_profiles', false);
$profiles_table_sql = StifliFlexMcpUtils::wrapTableNameForQuery($profiles_table);
// Check if system profile
$system_query = sprintf('SELECT is_system FROM %s WHERE id = %%d', $profiles_table_sql);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- query uses sprintf with safe table wrapper.
$is_system = $wpdb->get_var($wpdb->prepare($system_query, $profile_id));
if ($is_system === null) {
wp_send_json_error(array('message' => 'Profile not found'));
}
if (intval($is_system) === 1) {
wp_send_json_error(array('message' => 'Cannot delete system profiles'));
}
$wpdb->delete($profiles_table, array('id' => $profile_id), array('%d'));
wp_send_json_success();
}
public function ajax_duplicate_profile() {
if (!current_user_can('manage_options')) {
wp_send_json_error(array('message' => 'No permission'), 403);
}
check_ajax_referer('sflmcp_profiles');
global $wpdb;
$profile_id = isset($_POST['profile_id']) ? absint( wp_unslash( $_POST['profile_id'] ) ) : 0;
$profiles_table = StifliFlexMcpUtils::getPrefixedTable('sflmcp_profiles', false);
$profile_tools_table = StifliFlexMcpUtils::getPrefixedTable('sflmcp_profile_tools', false);
$profiles_table_sql = StifliFlexMcpUtils::wrapTableNameForQuery($profiles_table);
$profile_tools_table_sql = StifliFlexMcpUtils::wrapTableNameForQuery($profile_tools_table);
// Get original profile
$profile_query = sprintf('SELECT * FROM %s WHERE id = %%d', $profiles_table_sql);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- query uses sprintf with safe table wrapper.
$original = $wpdb->get_row($wpdb->prepare($profile_query, $profile_id), ARRAY_A);
if (!$original) {
wp_send_json_error(array('message' => 'Profile not found'));
}
// Create new profile name
$new_name = 'Copia de ' . $original['profile_name'];
$counter = 1;
$profile_name_check = sprintf('SELECT id FROM %s WHERE profile_name = %%s', $profiles_table_sql);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- query uses sprintf with safe table wrapper.
while ($wpdb->get_var($wpdb->prepare($profile_name_check, $new_name))) {
$counter++;
$new_name = 'Copia de ' . $original['profile_name'] . ' (' . $counter . ')';
}
$now = current_time('mysql', true);
// Insert new profile
$wpdb->insert(
$profiles_table,
array(
'profile_name' => $new_name,
'profile_description' => $original['profile_description'],
'is_system' => 0, // Duplicates are always custom
'is_active' => 0,
'created_at' => $now,
'updated_at' => $now,
),
array('%s', '%s', '%d', '%d', '%s', '%s')
);
$new_profile_id = $wpdb->insert_id;
// Copy tools
$tools_query = sprintf('SELECT tool_name FROM %s WHERE profile_id = %%d', $profile_tools_table_sql);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- query uses sprintf with safe table wrapper.
$tools = $wpdb->get_col($wpdb->prepare($tools_query, $profile_id));
foreach ($tools as $tool_name) {
$wpdb->insert(
$profile_tools_table,
array(
'profile_id' => $new_profile_id,
'tool_name' => $tool_name,
'created_at' => $now,
),
array('%d', '%s', '%s')
);
}
wp_send_json_success();
}
public function ajax_export_profile() {
if (!current_user_can('manage_options')) {
wp_die('No permission', 403);
}
check_ajax_referer('sflmcp_profiles');
global $wpdb;
$profile_id = intval($_GET['profile_id'] ?? 0);
$profiles_table = StifliFlexMcpUtils::getPrefixedTable('sflmcp_profiles', false);
$profile_tools_table = StifliFlexMcpUtils::getPrefixedTable('sflmcp_profile_tools', false);
$tools_table = StifliFlexMcpUtils::getPrefixedTable('sflmcp_tools', false);
$profiles_table_sql = StifliFlexMcpUtils::wrapTableNameForQuery($profiles_table);
$profile_tools_table_sql = StifliFlexMcpUtils::wrapTableNameForQuery($profile_tools_table);
$tools_table_sql = StifliFlexMcpUtils::wrapTableNameForQuery($tools_table);
$profile_query = sprintf('SELECT * FROM %s WHERE id = %%d', $profiles_table_sql);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- query uses sprintf with safe table wrapper.
$profile = $wpdb->get_row($wpdb->prepare($profile_query, $profile_id), ARRAY_A);
if (!$profile) {
wp_die('Profile not found', 404);
}
$tools_query = sprintf('SELECT tool_name FROM %s WHERE profile_id = %%d ORDER BY tool_name', $profile_tools_table_sql);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- query uses sprintf with safe table wrapper.
$tools = $wpdb->get_col($wpdb->prepare($tools_query, $profile_id));
// Get categories
$categories = array();
if (!empty($tools)) {
$placeholders = implode(',', array_fill(0, count($tools), '%s'));
$categories_query = 'SELECT DISTINCT category FROM ' . $tools_table_sql . ' WHERE tool_name IN (' . $placeholders . ') ORDER BY category';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- query uses dynamic placeholders with prepare.
$categories = $wpdb->get_col($wpdb->prepare($categories_query, ...$tools));
}
$export = array(
'format_version' => '1.0',
'export_date' => gmdate('Y-m-d\TH:i:s\Z'),
'plugin_version' => '0.1.0',
'profile' => array(
'name' => $profile['profile_name'],
'description' => $profile['profile_description'],
'tools' => $tools,
'tools_count' => count($tools),
'categories_included' => $categories,
),
);
$filename = sanitize_file_name($profile['profile_name']) . '-profile.json';
header('Content-Type: application/json');
header('Content-Disposition: attachment; filename="' . $filename . '"');
echo json_encode($export, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
exit;
}
public function ajax_import_profile() {
if (!current_user_can('manage_options')) {
wp_send_json_error(array('message' => 'No permission'), 403);
}
check_ajax_referer('sflmcp_profiles');
global $wpdb;
$profiles_table = StifliFlexMcpUtils::getPrefixedTable('sflmcp_profiles', false);
$profile_tools_table = StifliFlexMcpUtils::getPrefixedTable('sflmcp_profile_tools', false);
$tools_table = StifliFlexMcpUtils::getPrefixedTable('sflmcp_tools', false);
$profiles_table_sql = StifliFlexMcpUtils::wrapTableNameForQuery($profiles_table);
$profile_tools_table_sql = StifliFlexMcpUtils::wrapTableNameForQuery($profile_tools_table);
$tools_table_sql = StifliFlexMcpUtils::wrapTableNameForQuery($tools_table);
$json_data = isset($_POST['profile_json']) ? StifliFlexMcpUtils::sanitizeJsonString( sanitize_text_field( wp_unslash( $_POST['profile_json'] ) ) ) : '';
if (empty($json_data)) {
wp_send_json_error(array('message' => 'No JSON data provided'));
}
$data = json_decode($json_data, true);
if (!$data || !isset($data['profile'])) {
wp_send_json_error(array('message' => 'Invalid JSON format'));
}
$profile = $data['profile'];
$name = sanitize_text_field($profile['name'] ?? 'Importado');
$description = sanitize_textarea_field($profile['description'] ?? '');
$tools_list = isset($profile['tools']) && is_array($profile['tools']) ? $profile['tools'] : array();
$tools = array();
foreach ($tools_list as $tool_name) {
if (!is_string($tool_name)) {
continue;
}
$clean_tool = sanitize_key($tool_name);
if ('' !== $clean_tool) {
$tools[] = $clean_tool;
}
}
// Check if name exists
$counter = 1;
$original_name = $name;
$profile_name_check = sprintf('SELECT id FROM %s WHERE profile_name = %%s', $profiles_table_sql);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- query uses sprintf with safe table wrapper.
while ($wpdb->get_var($wpdb->prepare($profile_name_check, $name))) {
$counter++;
$name = $original_name . ' (' . $counter . ')';
}
// Validate tools exist
$existing_tools_query = sprintf('SELECT tool_name FROM %s WHERE 1 = %%d', $tools_table_sql);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- query uses sprintf with safe table wrapper.
$existing_tools = $wpdb->get_col($wpdb->prepare($existing_tools_query, 1));
$valid_tools = array_intersect($tools, $existing_tools);
if (empty($valid_tools)) {
wp_send_json_error(array('message' => 'No valid tools found in profile'));
}
$now = current_time('mysql', true);
// Insert profile
$wpdb->insert(
$profiles_table,
array(
'profile_name' => $name,
'profile_description' => $description,
'is_system' => 0,
'is_active' => 0,
'created_at' => $now,
'updated_at' => $now,
),
array('%s', '%s', '%d', '%d', '%s', '%s')
);
$profile_id = $wpdb->insert_id;
// Insert tools
foreach ($valid_tools as $tool_name) {
$wpdb->insert(
$profile_tools_table,
array(
'profile_id' => $profile_id,
'tool_name' => $tool_name,
'created_at' => $now,
),
array('%d', '%s', '%s')
);
}
$ignored_count = count($tools) - count($valid_tools);
$message = 'Perfil importado: ' . count($valid_tools) . ' herramientas';
if ($ignored_count > 0) {
$message .= ' (' . $ignored_count . ' herramientas no encontradas fueron ignoradas)';
}
wp_send_json_success(array('message' => $message));
}
public function ajax_restore_system_profiles() {
if (!current_user_can('manage_options')) {
wp_send_json_error(array('message' => 'No permission'), 403);
}
check_ajax_referer('sflmcp_profiles');
global $wpdb;
$profiles_table = StifliFlexMcpUtils::getPrefixedTable('sflmcp_profiles', false);
$profile_tools_table = StifliFlexMcpUtils::getPrefixedTable('sflmcp_profile_tools', false);
$profiles_table_sql = StifliFlexMcpUtils::wrapTableNameForQuery($profiles_table);
$profile_tools_table_sql = StifliFlexMcpUtils::wrapTableNameForQuery($profile_tools_table);
// Delete existing system profiles
$system_ids_query = sprintf('SELECT id FROM %s WHERE is_system = %%d', $profiles_table_sql);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- query uses sprintf with safe table wrapper.
$system_ids = $wpdb->get_col($wpdb->prepare($system_ids_query, 1));
if (!empty($system_ids)) {
$placeholders = implode(',', array_fill(0, count($system_ids), '%d'));
$delete_relations_query = 'DELETE FROM ' . $profile_tools_table_sql . ' WHERE profile_id IN (' . $placeholders . ')';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- query uses dynamic placeholders with prepare.
$wpdb->query($wpdb->prepare($delete_relations_query, ...$system_ids));
$delete_profiles_query = sprintf('DELETE FROM %s WHERE is_system = %%d', $profiles_table_sql);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- query uses sprintf with safe table wrapper.
$wpdb->query($wpdb->prepare($delete_profiles_query, 1));
}
// Re-seed system profiles
stifli_flex_mcp_seed_system_profiles();
wp_send_json_success();
}
public function ajax_create_profile() {
if (!current_user_can('manage_options')) {
wp_send_json_error(array('message' => 'No permission'), 403);
}
check_ajax_referer('sflmcp_profiles');
// TODO: Implement create/edit modal in next phase
wp_send_json_error(array('message' => 'Not implemented yet'));
}
public function ajax_update_profile() {
if (!current_user_can('manage_options')) {
wp_send_json_error(array('message' => 'No permission'), 403);
}
check_ajax_referer('sflmcp_profiles');
// TODO: Implement create/edit modal in next phase
wp_send_json_error(array('message' => 'Not implemented yet'));
}
/**
* Register top-level admin menu.
* Only creates the parent — submenus are added by priority:
* Priority 10: AI Chat Agent (client class, same slug as parent → first item)
* Priority 20: MCP Server (this class → second item)
*/
public function registerAdmin() {
add_menu_page(
__('StifLi Flex MCP', 'stifli-flex-mcp'),
__('StifLi Flex MCP', 'stifli-flex-mcp'),
'manage_options',
'stifli-flex-mcp',
'__return_null',
'dashicons-rest-api',
30
);
}
/**
* Register MCP Server submenu at priority 20 (after AI Chat Agent at priority 10).
*/
public function registerMcpServerSubmenu() {
add_submenu_page(
'stifli-flex-mcp',
__('MCP Server', 'stifli-flex-mcp'),
__('MCP Server', 'stifli-flex-mcp'),
'manage_options',
'sflmcp-server',
array($this, 'adminPage')
);
}
/**
* Register Multimedia submenu at priority 25 (after MCP Server at 15).
*/
public function registerMultimediaSubmenu() {
add_submenu_page(
'stifli-flex-mcp',
__('Multimedia', 'stifli-flex-mcp'),
__('Multimedia', 'stifli-flex-mcp'),
'manage_options',
'sflmcp-multimedia',
array($this, 'multimediaPage')
);
}
/**
* Register settings used by the plugin
*/
public function registerSettings() {
// No custom settings needed - uses WordPress Application Passwords
}
/**
* Check if Application Passwords are available for a given user.
*
* @param WP_User $user WordPress user object.
* @return bool
*/
private function isApplicationPasswordsAvailableForUser( $user ) {
if ( ! class_exists( 'WP_Application_Passwords' ) ) {
return false;
}
if ( function_exists( 'wp_is_application_passwords_available_for_user' ) ) {
return (bool) wp_is_application_passwords_available_for_user( $user );
}
if ( function_exists( 'wp_is_application_passwords_available' ) ) {
return (bool) wp_is_application_passwords_available();
}
return true;
}
/**
* Check if an application password name already exists for a user.
*
* @param int $user_id User ID.
* @param string $name Candidate application password name.
* @return bool
*/
private function applicationPasswordNameExistsForUser( $user_id, $name ) {
if ( class_exists( 'WP_Application_Passwords' ) && method_exists( 'WP_Application_Passwords', 'application_name_exists_for_user' ) ) {
return (bool) WP_Application_Passwords::application_name_exists_for_user( $user_id, $name );
}
if ( class_exists( 'WP_Application_Passwords' ) && method_exists( 'WP_Application_Passwords', 'get_user_application_passwords' ) ) {
$passwords = WP_Application_Passwords::get_user_application_passwords( $user_id );
foreach ( $passwords as $password ) {
$current_name = isset( $password['name'] ) ? (string) $password['name'] : '';
if ( 0 === strcasecmp( $current_name, $name ) ) {
return true;
}
}
}
return false;
}
/**
* Generate a unique default Application Password name for the current site.
*
* @param int $user_id User ID.
* @return string
*/
private function getNextDefaultApplicationPasswordName( $user_id ) {
$base_name = 'StifLi MCP App';
$candidate = $base_name;
$next_index = 2;
while ( $this->applicationPasswordNameExistsForUser( $user_id, $candidate ) ) {
$candidate = $base_name . ' ' . $next_index;
$next_index++;
if ( $next_index > 999 ) {
$candidate = $base_name . ' ' . wp_generate_password( 4, false, false );
break;
}
}
return $candidate;
}
private function getOAuthWellKnownProbeTransientKey() {
$host = strtolower((string) wp_parse_url(home_url('/'), PHP_URL_HOST));
if ('' === $host) {
$host = 'site';
}
return 'sflmcp_oauth_wk_probe_' . md5($host);
}
private function getOAuthWellKnownDismissOptionKey() {
$host = strtolower((string) wp_parse_url(home_url('/'), PHP_URL_HOST));
if ('' === $host) {
$host = 'site';
}
return 'sflmcp_oauth_wk_notice_dismissed_' . md5($host);
}
private function invalidateOAuthWellKnownProbeCache() {
delete_transient($this->getOAuthWellKnownProbeTransientKey());
delete_option($this->getOAuthWellKnownDismissOptionKey());
}
private function shouldSkipOAuthWellKnownSelfCheck() {
if (is_multisite() && !is_main_site()) {
return true;
}
if (function_exists('wp_get_environment_type')) {
$env = (string) wp_get_environment_type();
if (in_array($env, array('local', 'development'), true)) {
return true;
}
}
$host = strtolower((string) wp_parse_url(home_url('/'), PHP_URL_HOST));
if ('' === $host) {
return true;
}
if (in_array($host, array('localhost', '127.0.0.1', '::1'), true)) {
return true;
}
$devSuffixes = array('.local', '.test', '.example', '.invalid', '.localhost');
foreach ($devSuffixes as $suffix) {
if (strlen($host) > strlen($suffix) && substr($host, -strlen($suffix)) === $suffix) {
return true;
}
}
if (filter_var($host, FILTER_VALIDATE_IP)) {
$publicIp = filter_var(
$host,
FILTER_VALIDATE_IP,
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
);
if (false === $publicIp) {
return true;
}
}
return false;
}
private function getOAuthWellKnownSelfCheckResult() {
if ($this->shouldSkipOAuthWellKnownSelfCheck()) {
return array(
'status' => 'skipped',
'blocked' => false,
'checked_at' => time(),
);
}
$transientKey = $this->getOAuthWellKnownProbeTransientKey();
$cached = get_transient($transientKey);
if (is_array($cached) && isset($cached['status'])) {
return $cached;
}
$url = home_url('/.well-known/oauth-authorization-server');
$result = array(
'status' => 'unknown',
'blocked' => false,
'checked_at' => time(),
'url' => $url,
'code' => 0,
'message' => '',
);
$response = wp_remote_get($url, array(
'timeout' => 8,
'redirection' => 2,
));
if (is_wp_error($response)) {
$result['status'] = 'unknown';
$result['message'] = $response->get_error_message();
set_transient($transientKey, $result, (int) $this->oauthWellKnownProbeTtl);
return $result;
}
$code = (int) wp_remote_retrieve_response_code($response);
$body = (string) wp_remote_retrieve_body($response);
$result['code'] = $code;
if (200 === $code) {
$decoded = json_decode($body, true);
if (is_array($decoded) && !empty($decoded['authorization_endpoint']) && !empty($decoded['token_endpoint'])) {
$result['status'] = 'ok';
} else {
$result['status'] = 'unexpected';
$result['message'] = 'Unexpected payload at oauth-authorization-server endpoint.';
}
} elseif (in_array($code, array(403, 404), true)) {
$result['status'] = 'blocked';
$result['blocked'] = true;
$result['message'] = 'Host likely blocks /.well-known/* before WordPress routing.';
} else {
$result['status'] = 'error';
$result['message'] = 'Unexpected HTTP status from oauth-authorization-server endpoint.';
}
set_transient($transientKey, $result, (int) $this->oauthWellKnownProbeTtl);
return $result;
}
public function handleOAuthWellKnownNoticeDismiss() {
if (!current_user_can('manage_options')) {
return;
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- nonce verified below.
$dismiss = isset($_GET['sflmcp_dismiss_oauth_well_known_notice']) ? sanitize_text_field(wp_unslash($_GET['sflmcp_dismiss_oauth_well_known_notice'])) : '';
if ('1' !== $dismiss) {
return;
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- nonce is explicitly validated.
$nonce = isset($_GET['_sflmcpnonce']) ? sanitize_text_field(wp_unslash($_GET['_sflmcpnonce'])) : '';
if (!wp_verify_nonce($nonce, 'sflmcp_dismiss_oauth_well_known_notice')) {
return;
}
update_option($this->getOAuthWellKnownDismissOptionKey(), time(), false);
$redirect = remove_query_arg(array('sflmcp_dismiss_oauth_well_known_notice', '_sflmcpnonce'));
wp_safe_redirect($redirect);
exit;
}
public function renderOAuthWellKnownNotice() {
if (!current_user_can('manage_options')) {
return;
}
if (!function_exists('get_current_screen')) {
return;
}
$screen = get_current_screen();
if (!$screen || empty($screen->id)) {
return;
}
$allowedScreens = array('stifli-flex-mcp_page_sflmcp-server', 'plugins');
if (!in_array((string) $screen->id, $allowedScreens, true)) {
return;
}
$check = $this->getOAuthWellKnownSelfCheckResult();
if (empty($check['blocked'])) {
return;
}
$dismissedAt = (int) get_option($this->getOAuthWellKnownDismissOptionKey(), 0);
$checkedAt = isset($check['checked_at']) ? (int) $check['checked_at'] : 0;
if ($dismissedAt > 0 && $dismissedAt >= $checkedAt) {
return;
}
$dismissUrl = wp_nonce_url(
add_query_arg(array('sflmcp_dismiss_oauth_well_known_notice' => '1')),
'sflmcp_dismiss_oauth_well_known_notice',
'_sflmcpnonce'
);
$helpUrl = admin_url('admin.php?page=sflmcp-server&tab=help#troubleshooting');
$endpoint = isset($check['url']) ? (string) $check['url'] : home_url('/.well-known/oauth-authorization-server');
$code = isset($check['code']) ? (int) $check['code'] : 0;
?>
enqueueMultimediaAssets();
return;
}
// Only load on our MCP Server page
if ($hook !== 'stifli-flex-mcp_page_sflmcp-server') {
return;
}
// Get active tab early for conditional loading
$active_tab = isset($_GET['tab']) ? sanitize_text_field( wp_unslash( $_GET['tab'] ) ) : 'settings'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
// Enqueue Settings tab JavaScript
wp_enqueue_script(
'sflmcp-admin-settings',
plugin_dir_url(__FILE__) . 'assets/admin-settings.js',
array(),
'1.0.4',
true
);
// Localize script with data
wp_localize_script('sflmcp-admin-settings', 'sflmcpSettings', array(
'ajaxUrl' => admin_url('admin-ajax.php'),
'nonce' => wp_create_nonce('SFLMCP-admin'),
'i18n' => array(
'urlCopied' => __('URL copied', 'stifli-flex-mcp'),
'headerCopied' => __('Header copied', 'stifli-flex-mcp'),
'appPasswordGenerating' => __('Generating...', 'stifli-flex-mcp'),
'appPasswordGenerateError' => __('Could not generate Application Password. Please try again.', 'stifli-flex-mcp'),
),
));
// Enqueue Profiles tab JavaScript
wp_enqueue_script(
'sflmcp-admin-profiles',
plugin_dir_url(__FILE__) . 'assets/admin-profiles.js',
array(),
'1.0.1',
true
);
// Localize script with data
wp_localize_script('sflmcp-admin-profiles', 'sflmcpProfiles', array(
'nonce' => wp_create_nonce('sflmcp_profiles'),
'i18n' => array(
'includedTools' => __('Included tools:', 'stifli-flex-mcp'),
),
));
// Enqueue main admin styles (tools, help page)
wp_enqueue_style(
'sflmcp-admin-styles',
plugin_dir_url(__FILE__) . 'assets/admin-styles.css',
array(),
'1.0.5'
);
// Enqueue Custom Tools assets for legacy tab.
if ($active_tab === 'custom') {
$this->enqueueCustomToolsAssets();
}
// Enqueue Abilities tab assets (WordPress 6.9+)
if ($active_tab === 'abilities' && stifli_flex_mcp_abilities_available()) {
wp_enqueue_style(
'sflmcp-admin-abilities',
plugin_dir_url(__FILE__) . 'assets/admin-abilities.css',
array(),
'1.0.0'
);
wp_enqueue_script(
'sflmcp-admin-abilities',
plugin_dir_url(__FILE__) . 'assets/admin-abilities.js',
array('jquery'),
'1.0.0',
true
);
wp_localize_script('sflmcp-admin-abilities', 'sflmcpAbilities', array(
'ajaxUrl' => admin_url('admin-ajax.php'),
'nonce' => wp_create_nonce('sflmcp_abilities'),
'i18n' => array(
'discovering' => __('Discovering abilities...', 'stifli-flex-mcp'),
'discoverButton' => __('Discover Abilities', 'stifli-flex-mcp'),
'noAbilities' => __('No abilities found. Install plugins that register WordPress Abilities.', 'stifli-flex-mcp'),
'confirmDelete' => __('Are you sure you want to remove this ability?', 'stifli-flex-mcp'),
'confirmBulkRemove' => __('Remove the selected imported abilities?', 'stifli-flex-mcp'),
'confirmBulkImport' => __('Import the selected discovered abilities?', 'stifli-flex-mcp'),
'imported' => __('Ability imported successfully', 'stifli-flex-mcp'),
'deleted' => __('Ability removed', 'stifli-flex-mcp'),
'error' => __('An error occurred', 'stifli-flex-mcp'),
'chooseBulkAction' => __('Select a bulk action first.', 'stifli-flex-mcp'),
'noSelection' => __('Select at least one ability.', 'stifli-flex-mcp'),
'applying' => __('Applying...', 'stifli-flex-mcp'),
'alreadyImported' => __('Already imported', 'stifli-flex-mcp'),
'import' => __('Import', 'stifli-flex-mcp'),
'importing' => __('Importing...', 'stifli-flex-mcp'),
'enabledLabel' => __('Enabled', 'stifli-flex-mcp'),
'disabledLabel' => __('Disabled', 'stifli-flex-mcp'),
'selectedSuffix' => __('selected', 'stifli-flex-mcp'),
'importedSuffix' => __('imported', 'stifli-flex-mcp'),
'visibleSuffix' => __('visible', 'stifli-flex-mcp'),
'allCategories' => __('All categories', 'stifli-flex-mcp'),
'selectAllVisible' => __('Select all visible', 'stifli-flex-mcp'),
'clearSelection' => __('Clear selection', 'stifli-flex-mcp'),
'importSelected' => __('Import selected', 'stifli-flex-mcp'),
'importVisible' => __('Import visible', 'stifli-flex-mcp'),
),
));
}
// Enqueue OAuth assets on Settings tab
if ($active_tab === 'settings') {
wp_enqueue_style(
'sflmcp-admin-oauth',
plugin_dir_url(__FILE__) . 'assets/admin-oauth.css',
array(),
'1.0.0'
);
wp_enqueue_script(
'sflmcp-admin-oauth',
plugin_dir_url(__FILE__) . 'assets/admin-oauth.js',
array('jquery'),
'1.0.0',
true
);
wp_localize_script('sflmcp-admin-oauth', 'sflmcpOAuth', array(
'ajaxUrl' => admin_url('admin-ajax.php'),
'nonce' => wp_create_nonce('sflmcp_oauth'),
'i18n' => array(
'confirmDeleteClient' => __('Are you sure you want to delete this OAuth client and revoke all its tokens?', 'stifli-flex-mcp'),
'confirmRevokeToken' => __('Revoke this token? The client will need to re-authorize.', 'stifli-flex-mcp'),
'confirmResetState' => __('Reset all OAuth clients, access tokens, refresh tokens, and pending authorization codes? All MCP clients will need to authorize again.', 'stifli-flex-mcp'),
'clientDeleted' => __('OAuth client deleted', 'stifli-flex-mcp'),
'tokenRevoked' => __('Token revoked', 'stifli-flex-mcp'),
'resetStateSuccess' => __('OAuth state reset. Reloading...', 'stifli-flex-mcp'),
'settingsSaved' => __('Settings saved', 'stifli-flex-mcp'),
'error' => __('An error occurred', 'stifli-flex-mcp'),
),
));
}
// Enqueue Tools tab JavaScript (WordPress and WooCommerce tools)
if ($active_tab === 'tools' || $active_tab === 'wc_tools') {
wp_enqueue_script(
'sflmcp-admin-tools',
plugin_dir_url(__FILE__) . 'assets/admin-tools.js',
array('jquery'),
'1.0.5',
true
);
wp_localize_script('sflmcp-admin-tools', 'sflmcpTools', array(
'ajaxUrl' => admin_url('admin-ajax.php'),
'nonce' => wp_create_nonce('sflmcp_tools'),
'i18n' => array(
'enabled' => __('Enabled', 'stifli-flex-mcp'),
'disabled' => __('Disabled', 'stifli-flex-mcp'),
'error' => __('Error updating tool', 'stifli-flex-mcp'),
),
));
}
// Extension point for modular admin tabs.
do_action('sflmcp_admin_enqueue_tab_assets', $active_tab, $hook, $this);
}
/**
* Enqueue assets used by the Custom Tools UI.
*/
public function enqueueCustomToolsAssets() {
wp_enqueue_style(
'sflmcp-admin-custom-tools',
plugin_dir_url(__FILE__) . 'assets/admin-custom-tools.css',
array(),
'1.0.5'
);
wp_enqueue_script(
'sflmcp-admin-custom-tools',
plugin_dir_url(__FILE__) . 'assets/admin-custom-tools.js',
array('jquery'),
'1.0.5',
true
);
wp_localize_script('sflmcp-admin-custom-tools', 'sflmcpCustom', array(
'ajaxUrl' => admin_url('admin-ajax.php'),
'nonce' => wp_create_nonce('sflmcp_custom_tools'),
'i18n' => array(
'confirmDelete' => __('Are you sure you want to delete this tool?', 'stifli-flex-mcp'),
'errorSaving' => __('Error saving tool', 'stifli-flex-mcp'),
'saved' => __('Tool saved successfully', 'stifli-flex-mcp'),
'testing' => __('Testing...', 'stifli-flex-mcp'),
'success' => __('Success', 'stifli-flex-mcp'),
'failed' => __('Failed', 'stifli-flex-mcp'),
),
));
}
/**
* Render the admin settings page
*/
public function adminPage() {
if (!current_user_can('manage_options')) {
wp_die( esc_html__('You do not have permission to view this page.','stifli-flex-mcp') );
}
// Get active tab
$active_tab = isset($_GET['tab']) ? sanitize_text_field( wp_unslash( $_GET['tab'] ) ) : 'settings'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- tab selection is a read-only navigation parameter
$tabs = array(
'settings' => __('Settings', 'stifli-flex-mcp'),
'profiles' => __('Profiles', 'stifli-flex-mcp'),
'tools' => __('WordPress Tools', 'stifli-flex-mcp'),
'wc_tools' => __('WooCommerce Tools', 'stifli-flex-mcp'),
);
if ( stifli_flex_mcp_abilities_available() ) {
$tabs['abilities'] = __('Abilities', 'stifli-flex-mcp');
}
$tabs['help'] = __('📚 Help', 'stifli-flex-mcp');
$tabs = apply_filters('sflmcp_admin_tabs', $tabs, $active_tab, $this);
?>
$tab_label) : ?>
renderSettingsTab();
$handled = true;
} elseif ($active_tab === 'profiles') {
$this->renderProfilesTab();
$handled = true;
} elseif ($active_tab === 'tools') {
$this->renderToolsTab();
$handled = true;
} elseif ($active_tab === 'wc_tools') {
$this->renderWCToolsTab();
$handled = true;
} elseif ($active_tab === 'abilities' && stifli_flex_mcp_abilities_available()) {
$this->renderAbilitiesTab();
$handled = true;
} elseif ($active_tab === 'custom' && (bool) apply_filters('sflmcp_enable_legacy_custom_tab', false, $this)) {
$this->renderCustomToolsTab();
$handled = true;
} elseif ($active_tab === 'help') {
$this->renderHelpTab();
$handled = true;
}
if (!$handled) {
$external_renderers = apply_filters('sflmcp_admin_tab_renderers', array(), $this);
if (isset($external_renderers[$active_tab]) && is_callable($external_renderers[$active_tab])) {
call_user_func($external_renderers[$active_tab], $active_tab, $this);
$handled = true;
}
}
if (!$handled) {
$this->renderSettingsTab();
}
?>
namespace . '/sse' );
if ( ! class_exists( 'StifliFlexMcp_OAuth_Storage' ) ) {
echo '
' . esc_html__( 'OAuth module is not loaded.', 'stifli-flex-mcp' ) . '
';
return;
}
$storage = StifliFlexMcp_OAuth_Storage::get_instance();
$clients = $storage->get_all_clients();
// Preload token data.
global $wpdb;
$tokens_table = $wpdb->prefix . 'sflmcp_oauth_tokens';
$now = gmdate( 'Y-m-d H:i:s' );
$token_counts = array();
$token_data = array();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$counts_raw = $wpdb->get_results(
$wpdb->prepare(
"SELECT client_id, COUNT(*) as cnt FROM {$tokens_table} WHERE revoked = 0 AND access_expires_at > %s GROUP BY client_id",
$now
)
);
foreach ( $counts_raw as $row ) {
$token_counts[ $row->client_id ] = (int) $row->cnt;
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$tokens_raw = $wpdb->get_results(
$wpdb->prepare(
"SELECT t.id, t.client_id, t.user_id, t.scope, t.access_expires_at, t.created_at, u.display_name as user_name
FROM {$tokens_table} t
LEFT JOIN {$wpdb->users} u ON t.user_id = u.ID
WHERE t.revoked = 0 AND t.access_expires_at > %s
ORDER BY t.created_at DESC",
$now
)
);
foreach ( $tokens_raw as $tok ) {
$token_data[ $tok->client_id ][] = $tok;
}
?>
client_id );
$count = isset( $token_counts[ $client->client_id ] ) ? $token_counts[ $client->client_id ] : 0;
$tokens_list = isset( $token_data[ $client->client_id ] ) ? $token_data[ $client->client_id ] : array();
?>
client_name ); ?>
client_id ); ?>
0 ) : ?>
0
created_at );
echo esc_html( date_i18n( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $registered + ( get_option( 'gmt_offset' ) * HOUR_IN_SECONDS ) ) );
?>
created_at );
$expires = strtotime( $tok->access_expires_at );
$offset = get_option( 'gmt_offset' ) * HOUR_IN_SECONDS;
?>
user_name ?: '#' . $tok->user_id ); ?>
scope ); ?>
'
. esc_html__( 'your profile', 'stifli-flex-mcp' ) . ''
),
array( 'a' => array( 'href' => array(), 'target' => array() ) )
); ?>
namespace . '/messages' );
$current_user = wp_get_current_user();
$example_user = ( $current_user && ! empty( $current_user->user_login ) ) ? $current_user->user_login : 'your_wp_username';
$curl_example = implode(
"\n",
array(
sprintf( 'curl -u "%s:
" \\', $example_user ),
' -H "Content-Type: application/json" \\',
' -d \'{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}\' \\',
sprintf( ' "%s"', $messages_endpoint ),
)
);
?>
' . esc_html( $messages_endpoint ) . ''
),
array( 'code' => array() )
); ?>
getModel();
$integration_managed_tools = $this->getIntegrationManagedTools($model);
global $wpdb;
$table = StifliFlexMcpUtils::getPrefixedTable('sflmcp_tools', false);
$profiles_table = StifliFlexMcpUtils::getPrefixedTable('sflmcp_profiles', false);
$table_sql = StifliFlexMcpUtils::wrapTableNameForQuery($table);
$profiles_table_sql = StifliFlexMcpUtils::wrapTableNameForQuery($profiles_table);
// Check if there's an active profile
$active_profile_query = sprintf('SELECT * FROM %s WHERE is_active = %%d', $profiles_table_sql);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- query uses sprintf with safe table wrapper.
$active_profile = $wpdb->get_row($wpdb->prepare($active_profile_query, 1), ARRAY_A);
// Handle re-seeding
$reseed_nonce = isset($_POST['sflmcp_reseed_nonce']) ? sanitize_text_field( wp_unslash( $_POST['sflmcp_reseed_nonce'] ) ) : '';
if (!empty($reseed_nonce) && wp_verify_nonce($reseed_nonce, 'sflmcp_reseed_tools')) {
$truncate_query = sprintf('TRUNCATE TABLE %s', $table_sql);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.NotPrepared -- admin action intentionally resets plugin-managed table.
$wpdb->query($truncate_query);
stifli_flex_mcp_seed_initial_tools();
echo '
' . esc_html__('Tools reset and reseeded successfully.', 'stifli-flex-mcp') . '
';
}
// Get all tools grouped by category (ONLY WordPress, excluding WooCommerce).
$tools_query = sprintf('SELECT * FROM %s WHERE category NOT LIKE %%s ORDER BY category, tool_name', $table_sql);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- query uses sprintf with safe table wrapper.
$tools = $wpdb->get_results($wpdb->prepare($tools_query, 'WooCommerce%'), ARRAY_A);
if (!empty($integration_managed_tools)) {
$tools = array_values(array_filter($tools, function($tool) use ($integration_managed_tools) {
$name = isset($tool['tool_name']) ? (string) $tool['tool_name'] : '';
return '' === $name || !isset($integration_managed_tools[$name]);
}));
}
$enabled_token_total = 0;
foreach ($tools as $tool_row) {
if (intval($tool_row['enabled']) === 1) {
$enabled_token_total += intval($tool_row['token_estimate']);
}
}
$grouped_tools = array();
foreach ($tools as $tool) {
$category = $tool['category'];
if (!isset($grouped_tools[$category])) {
$grouped_tools[$category] = array();
}
$grouped_tools[$category][] = $tool;
}
?>
$category_tools): ?>
getToolModeLabel($model, $tool_meta['tool_name']);
if ('WRITE' === $tool_mode) {
$category_write++;
} else {
$category_read++;
}
if (intval($tool_meta['enabled']) === 1) {
$category_enabled++;
$category_enabled_tokens += intval($tool_meta['token_estimate']);
if ('WRITE' === $tool_mode) {
$category_enabled_write++;
} else {
$category_enabled_read++;
}
}
}
$count_class = ($category_enabled === count($category_tools) && count($category_tools) > 0) ? 'sflmcp-count-full' : ($category_enabled > 0 ? 'sflmcp-count-partial' : '');
$summary_html = esc_html($category_enabled . '/' . count($category_tools) . ' enabled');
if ($category_enabled_read > 0) {
$summary_html .= ' ·
' . esc_html($category_enabled_read . ' read') . ' ';
}
if ($category_enabled_write > 0) {
$summary_html .= ' ·
' . esc_html($category_enabled_write . ' write') . ' ';
}
if ($category_enabled > 0) {
$summary_html .= ' · ' . esc_html(number_format_i18n($category_enabled_tokens)) . ' tokens';
}
?>
/>
▸
getModel();
global $wpdb;
$table = StifliFlexMcpUtils::getPrefixedTable('sflmcp_tools', false);
$profiles_table = StifliFlexMcpUtils::getPrefixedTable('sflmcp_profiles', false);
$table_sql = StifliFlexMcpUtils::wrapTableNameForQuery($table);
$profiles_table_sql = StifliFlexMcpUtils::wrapTableNameForQuery($profiles_table);
// Check if there's an active profile
$active_profile_query = sprintf('SELECT * FROM %s WHERE is_active = %%d', $profiles_table_sql);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- query uses sprintf with safe table wrapper.
$active_profile = $wpdb->get_row($wpdb->prepare($active_profile_query, 1), ARRAY_A);
// Get all WooCommerce tools grouped by category
$wc_tools_query = sprintf("SELECT * FROM %s WHERE category LIKE %%s ORDER BY category, tool_name", $table_sql);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- query uses sprintf with safe table wrapper.
$tools = $wpdb->get_results($wpdb->prepare($wc_tools_query, 'WooCommerce%'), ARRAY_A);
$wc_token_sum_query = sprintf("SELECT COALESCE(SUM(token_estimate),0) FROM %s WHERE category LIKE %%s AND enabled = %%d", $table_sql);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- query uses sprintf with safe table wrapper.
$enabled_token_total = (int) $wpdb->get_var($wpdb->prepare($wc_token_sum_query, 'WooCommerce%', 1));
$grouped_tools = array();
foreach ($tools as $tool) {
$category = $tool['category'];
if (!isset($grouped_tools[$category])) {
$grouped_tools[$category] = array();
}
$grouped_tools[$category][] = $tool;
}
?>
$category_tools): ?>
getToolModeLabel($model, $tool_meta['tool_name']);
if ('WRITE' === $tool_mode) {
$category_write++;
} else {
$category_read++;
}
if (intval($tool_meta['enabled']) === 1) {
$category_enabled++;
$category_enabled_tokens += intval($tool_meta['token_estimate']);
if ('WRITE' === $tool_mode) {
$category_enabled_write++;
} else {
$category_enabled_read++;
}
}
}
$count_class = ($category_enabled === count($category_tools) && count($category_tools) > 0) ? 'sflmcp-count-full' : ($category_enabled > 0 ? 'sflmcp-count-partial' : '');
$summary_html = esc_html($category_enabled . '/' . count($category_tools) . ' enabled');
if ($category_enabled_read > 0) {
$summary_html .= ' ·
' . esc_html($category_enabled_read . ' read') . ' ';
}
if ($category_enabled_write > 0) {
$summary_html .= ' ·
' . esc_html($category_enabled_write . ' write') . ' ';
}
if ($category_enabled > 0) {
$summary_html .= ' · ' . esc_html(number_format_i18n($category_enabled_tokens)) . ' tokens';
}
?>
/>
▸
getTools();
if (!is_array($all_tools)) {
return $managed;
}
foreach (array_keys($all_tools) as $tool_name) {
if (!empty(StifliFlexMcp_Plugin_Integrations_Registry::get_integrations_for_tool($tool_name))) {
$managed[$tool_name] = true;
}
}
return $managed;
}
private function getToolModeLabel($model, $tool_name) {
if (!is_object($model) || !method_exists($model, 'getIntentForTool')) {
return 'READ';
}
$meta = $model->getIntentForTool((string) $tool_name);
if (is_array($meta) && isset($meta['intent']) && 'write' === $meta['intent']) {
return 'WRITE';
}
return 'READ';
}
private function renderProfilesTab() {
global $wpdb;
$profiles_table = StifliFlexMcpUtils::getPrefixedTable('sflmcp_profiles', false);
$profile_tools_table = StifliFlexMcpUtils::getPrefixedTable('sflmcp_profile_tools', false);
$tools_table = StifliFlexMcpUtils::getPrefixedTable('sflmcp_tools', false);
$profiles_table_sql = StifliFlexMcpUtils::wrapTableNameForQuery($profiles_table);
$profile_tools_table_sql = StifliFlexMcpUtils::wrapTableNameForQuery($profile_tools_table);
$tools_table_sql = StifliFlexMcpUtils::wrapTableNameForQuery($tools_table);
// Get all profiles with tool count and estimated tokens
$profiles_query = sprintf(
"SELECT p.*, COUNT(pt.id) AS tools_count, COALESCE(SUM(t.token_estimate),0) AS tokens_sum\n"
."FROM %s p\n"
."LEFT JOIN %s pt ON p.id = pt.profile_id\n"
."LEFT JOIN %s t ON pt.tool_name = t.tool_name\n"
."WHERE 1 = %%d\n"
."GROUP BY p.id\n"
."ORDER BY p.is_system DESC, p.profile_name ASC",
$profiles_table_sql,
$profile_tools_table_sql,
$tools_table_sql
);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- query uses sprintf with safe table wrapper.
$profiles = $wpdb->get_results($wpdb->prepare($profiles_query, 1), ARRAY_A);
$total_tools_query = sprintf('SELECT COUNT(*) FROM %s WHERE 1 = %%d', $tools_table_sql);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- query uses sprintf with safe table wrapper.
$total_tools = $wpdb->get_var($wpdb->prepare($total_tools_query, 1));
?>
get_results($wpdb->prepare($system_tools_query, $profile['id']), ARRAY_A);
$profile_tools_list = array();
if (!empty($profile_tools_rows)) {
foreach ($profile_tools_rows as $tool_row) {
$profile_tools_list[] = $tool_row['tool_name'];
}
}
$tools_list_html = !empty($profile_tools_list) ? implode(', ', $profile_tools_list) : esc_html__('None', 'stifli-flex-mcp');
?>
●
📋
get_results($wpdb->prepare($custom_tools_query, $profile['id']), ARRAY_A);
$profile_tools_list = array();
if (!empty($profile_tools_rows)) {
foreach ($profile_tools_rows as $tool_row) {
$token_str = number_format_i18n(intval($tool_row['token_estimate']));
$profile_tools_list[] = sprintf('%s (≈%s)', $tool_row['tool_name'], $token_str);
}
}
$tools_list_html = !empty($profile_tools_list) ? implode(', ', $profile_tools_list) : esc_html__('None', 'stifli-flex-mcp');
?>
●
📋
📚
get_results("SELECT * FROM $table ORDER BY created_at DESC");
wp_send_json_success($tools);
}
public function ajax_save_custom_tool() {
check_ajax_referer('sflmcp_custom_tools', 'nonce');
if (!current_user_can('manage_options')) wp_send_json_error();
global $wpdb;
$table = StifliFlexMcpUtils::getPrefixedTable('sflmcp_custom_tools');
$id = isset($_POST['id']) ? intval($_POST['id']) : 0;
$name = isset($_POST['tool_name']) ? sanitize_text_field( wp_unslash( $_POST['tool_name'] ) ) : '';
$desc = isset($_POST['tool_description']) ? sanitize_text_field( wp_unslash( $_POST['tool_description'] ) ) : '';
$method = isset($_POST['method']) ? sanitize_text_field( wp_unslash( $_POST['method'] ) ) : 'GET';
$endpoint = isset($_POST['endpoint']) ? esc_url_raw( wp_unslash( $_POST['endpoint'] ) ) : '';
$enabled = isset($_POST['enabled']) ? 1 : 0;
// Handle headers JSON
$headers = isset($_POST['headers']) ? sanitize_textarea_field( wp_unslash( $_POST['headers'] ) ) : '';
if (!empty($headers) && null === json_decode($headers)) {
wp_send_json_error(array('message' => 'Invalid JSON in headers'));
}
// Handle Arguments Builder -> JSON Schema
$args_json = isset($_POST['arguments']) ? sanitize_textarea_field( wp_unslash( $_POST['arguments'] ) ) : '{}';
// Validate that it's valid JSON
if (null === json_decode($args_json)) {
wp_send_json_error(array('message' => 'Invalid JSON in arguments'));
}
$data = array(
'tool_name' => $name,
'tool_description' => $desc,
'method' => $method,
'endpoint' => $endpoint,
'headers' => $headers,
'arguments' => $args_json,
'enabled' => $enabled
);
if ($id > 0) {
$wpdb->update($table, $data, array('id' => $id));
} else {
// Ensure unique name
if ($wpdb->get_var($wpdb->prepare("SELECT id FROM $table WHERE tool_name = %s", $name))) {
wp_send_json_error(array('message' => 'Tool name already exists'));
}
$wpdb->insert($table, $data);
}
wp_send_json_success();
}
public function ajax_delete_custom_tool() {
check_ajax_referer('sflmcp_custom_tools', 'nonce');
if (!current_user_can('manage_options')) wp_send_json_error();
global $wpdb;
$table = StifliFlexMcpUtils::getPrefixedTable('sflmcp_custom_tools');
$id = isset($_POST['id']) ? intval($_POST['id']) : 0;
if (!$id) {
wp_send_json_error(array('message' => __('Invalid tool ID', 'stifli-flex-mcp')));
return;
}
$wpdb->delete($table, array('id' => $id));
wp_send_json_success();
}
public function ajax_toggle_custom_tool() {
check_ajax_referer('sflmcp_custom_tools', 'nonce');
if (!current_user_can('manage_options')) {
wp_send_json_error(array('message' => __('Permission denied', 'stifli-flex-mcp')));
return;
}
global $wpdb;
$table = $wpdb->prefix . 'sflmcp_custom_tools';
$id = isset($_POST['id']) ? intval($_POST['id']) : 0;
if (!$id) {
wp_send_json_error(array('message' => __('Invalid tool ID', 'stifli-flex-mcp')));
return;
}
// Get current status
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Plugin-managed table with parameterized query.
$current = $wpdb->get_var($wpdb->prepare("SELECT enabled FROM $table WHERE id = %d", $id));
if ($current === null) {
wp_send_json_error(array('message' => __('Tool not found', 'stifli-flex-mcp')));
return;
}
$new_status = ($current == 1) ? 0 : 1;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->update($table, array('enabled' => $new_status), array('id' => $id));
wp_send_json_success(array('enabled' => $new_status));
}
public function ajax_test_custom_tool() {
check_ajax_referer('sflmcp_custom_tools', 'nonce');
if (!current_user_can('manage_options')) wp_send_json_error();
$endpoint = isset($_POST['endpoint']) ? esc_url_raw( wp_unslash( $_POST['endpoint'] ) ) : '';
$method = isset($_POST['method']) ? sanitize_text_field( wp_unslash( $_POST['method'] ) ) : 'GET';
$headers_raw = isset($_POST['headers']) ? sanitize_textarea_field( wp_unslash( $_POST['headers'] ) ) : '';
$test_args = array(
'test' => true,
'timestamp' => time(),
'source' => 'StifLi Flex MCP Test'
);
$args = array(
'method' => $method,
'timeout' => 15,
'user-agent' => 'StifLi-Flex-MCP/Tester'
);
if (!empty($headers_raw)) {
$h = json_decode($headers_raw, true);
if (is_array($h)) {
$args['headers'] = $h;
}
}
if ($method === 'GET') {
$endpoint = add_query_arg($test_args, $endpoint);
} else {
$args['body'] = wp_json_encode($test_args);
if (!isset($args['headers']['Content-Type'])) {
$args['headers']['Content-Type'] = 'application/json';
}
}
$response = wp_remote_request($endpoint, $args);
if (is_wp_error($response)) {
wp_send_json_error(array('message' => $response->get_error_message()));
} else {
$code = wp_remote_retrieve_response_code($response);
$body = wp_remote_retrieve_body($response);
wp_send_json_success(array('code' => $code, 'body' => substr($body, 0, 500))); // Truncate for preview
}
}
/**
* AJAX handler to toggle a single WordPress/WooCommerce tool
*/
public function ajax_toggle_tool() {
check_ajax_referer('sflmcp_tools', 'nonce');
if (!current_user_can('manage_options')) {
wp_send_json_error(array('message' => __('Permission denied', 'stifli-flex-mcp')));
return;
}
global $wpdb;
$table = $wpdb->prefix . 'sflmcp_tools';
$tool_id = isset($_POST['tool_id']) ? intval($_POST['tool_id']) : 0;
if (!$tool_id) {
wp_send_json_error(array('message' => __('Invalid tool ID', 'stifli-flex-mcp')));
return;
}
// Get current status
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$tool = $wpdb->get_row($wpdb->prepare("SELECT * FROM $table WHERE id = %d", $tool_id), ARRAY_A);
if (!$tool) {
wp_send_json_error(array('message' => __('Tool not found', 'stifli-flex-mcp')));
return;
}
$new_status = ($tool['enabled'] == 1) ? 0 : 1;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->update(
$table,
array('enabled' => $new_status, 'updated_at' => current_time('mysql', true)),
array('id' => $tool_id),
array('%d', '%s'),
array('%d')
);
// Sync to active profile if exists
$this->syncToolToActiveProfile($tool['tool_name'], $new_status);
// Calculate new token totals
$is_wc = strpos($tool['category'], 'WooCommerce') === 0;
$like_pattern = $is_wc ? 'WooCommerce%' : '';
if ($is_wc) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$total_tokens = $wpdb->get_var($wpdb->prepare(
"SELECT COALESCE(SUM(token_estimate),0) FROM $table WHERE category LIKE %s AND enabled = %d",
'WooCommerce%', 1
));
} else {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$total_tokens = $wpdb->get_var($wpdb->prepare(
"SELECT COALESCE(SUM(token_estimate),0) FROM $table WHERE category NOT LIKE %s AND enabled = %d",
'WooCommerce%', 1
));
}
wp_send_json_success(array(
'enabled' => $new_status,
'total_tokens' => number_format_i18n(intval($total_tokens))
));
}
/**
* AJAX handler to bulk toggle tools in a category
*/
public function ajax_bulk_toggle_tools() {
check_ajax_referer('sflmcp_tools', 'nonce');
if (!current_user_can('manage_options')) {
wp_send_json_error(array('message' => __('Permission denied', 'stifli-flex-mcp')));
return;
}
global $wpdb;
$table = $wpdb->prefix . 'sflmcp_tools';
$bulk_action = isset($_POST['bulk_action']) ? sanitize_text_field( wp_unslash( $_POST['bulk_action'] ) ) : '';
$category = isset($_POST['category']) ? sanitize_text_field( wp_unslash( $_POST['category'] ) ) : '';
if (!in_array($bulk_action, array('enable', 'disable'))) {
wp_send_json_error(array('message' => __('Invalid action', 'stifli-flex-mcp')));
return;
}
$new_status = ($bulk_action === 'enable') ? 1 : 0;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$wpdb->query($wpdb->prepare(
"UPDATE $table SET enabled = %d, updated_at = %s WHERE category = %s",
$new_status, current_time('mysql', true), $category
));
// Get affected tools and sync to active profile
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$affected_tools = $wpdb->get_col($wpdb->prepare(
"SELECT tool_name FROM $table WHERE category = %s",
$category
));
foreach ($affected_tools as $tool_name) {
$this->syncToolToActiveProfile($tool_name, $new_status);
}
wp_send_json_success();
}
/**
* Sync a tool's enabled status to the active profile
*/
private function syncToolToActiveProfile($tool_name, $enabled) {
global $wpdb;
$profiles_table = $wpdb->prefix . 'sflmcp_profiles';
$profile_tools_table = $wpdb->prefix . 'sflmcp_profile_tools';
// Get active profile
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$active_profile = $wpdb->get_row($wpdb->prepare(
"SELECT id FROM $profiles_table WHERE is_active = %d",
1
), ARRAY_A);
if (!$active_profile) {
return;
}
$profile_id = $active_profile['id'];
if ($enabled) {
// Check if already exists
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$exists = $wpdb->get_var($wpdb->prepare(
"SELECT id FROM $profile_tools_table WHERE profile_id = %d AND tool_name = %s",
$profile_id, $tool_name
));
if (!$exists) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->insert(
$profile_tools_table,
array('profile_id' => $profile_id, 'tool_name' => $tool_name),
array('%d', '%s')
);
}
} else {
// Remove from profile
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->delete(
$profile_tools_table,
array('profile_id' => $profile_id, 'tool_name' => $tool_name),
array('%d', '%s')
);
}
}
/**
* AJAX handler: toggle single tool via checkbox
*/
public function ajax_toggle_tool_by_checkbox() {
check_ajax_referer('sflmcp_tools', 'nonce');
if (!current_user_can('manage_options')) {
wp_send_json_error(array('message' => __('Permission denied', 'stifli-flex-mcp')));
return;
}
global $wpdb;
$table = $wpdb->prefix . 'sflmcp_tools';
$tool_id = isset($_POST['tool_id']) ? intval($_POST['tool_id']) : 0;
$enabled = isset($_POST['enabled']) ? intval($_POST['enabled']) : 0;
if (!$tool_id) {
wp_send_json_error(array('message' => __('Invalid tool ID', 'stifli-flex-mcp')));
return;
}
// Get tool info
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$tool = $wpdb->get_row($wpdb->prepare("SELECT * FROM $table WHERE id = %d", $tool_id), ARRAY_A);
if (!$tool) {
wp_send_json_error(array('message' => __('Tool not found', 'stifli-flex-mcp')));
return;
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->update(
$table,
array('enabled' => $enabled, 'updated_at' => current_time('mysql', true)),
array('id' => $tool_id),
array('%d', '%s'),
array('%d')
);
// Sync to active profile
$this->syncToolToActiveProfile($tool['tool_name'], $enabled);
// Calculate new token totals
$is_wc = strpos($tool['category'], 'WooCommerce') === 0;
if ($is_wc) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$total_tokens = $wpdb->get_var($wpdb->prepare(
"SELECT COALESCE(SUM(token_estimate),0) FROM $table WHERE category LIKE %s AND enabled = %d",
'WooCommerce%', 1
));
} else {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$total_tokens = $wpdb->get_var($wpdb->prepare(
"SELECT COALESCE(SUM(token_estimate),0) FROM $table WHERE category NOT LIKE %s AND enabled = %d",
'WooCommerce%', 1
));
}
wp_send_json_success(array(
'enabled' => $enabled,
'total_tokens' => number_format_i18n(intval($total_tokens))
));
}
/**
* AJAX handler: bulk toggle multiple tools by ID (from category checkbox)
*/
public function ajax_bulk_toggle_tools_by_id() {
check_ajax_referer('sflmcp_tools', 'nonce');
if (!current_user_can('manage_options')) {
wp_send_json_error(array('message' => __('Permission denied', 'stifli-flex-mcp')));
return;
}
global $wpdb;
$table = $wpdb->prefix . 'sflmcp_tools';
$tool_ids = isset($_POST['tool_ids']) && is_array($_POST['tool_ids']) ? array_map('intval', wp_unslash($_POST['tool_ids'])) : array();
if (empty($tool_ids)) {
wp_send_json_error(array('message' => __('No tools specified', 'stifli-flex-mcp')));
return;
}
// Get current enabled status of first tool to toggle
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$first_tool = $wpdb->get_row($wpdb->prepare(
"SELECT enabled FROM $table WHERE id = %d",
$tool_ids[0]
), ARRAY_A);
if (!$first_tool) {
wp_send_json_error(array('message' => __('Tool not found', 'stifli-flex-mcp')));
return;
}
// Toggle status
$new_status = ($first_tool['enabled'] == 1) ? 0 : 1;
// Update all tools
foreach ($tool_ids as $tool_id) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->update(
$table,
array('enabled' => $new_status, 'updated_at' => current_time('mysql', true)),
array('id' => $tool_id),
array('%d', '%s'),
array('%d')
);
// Get tool name for syncing
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$tool = $wpdb->get_row($wpdb->prepare("SELECT tool_name FROM $table WHERE id = %d", $tool_id), ARRAY_A);
if ($tool) {
$this->syncToolToActiveProfile($tool['tool_name'], $new_status);
}
}
// Get WC vs WordPress status from first tool
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$first_tool_full = $wpdb->get_row($wpdb->prepare("SELECT category FROM $table WHERE id = %d", $tool_ids[0]), ARRAY_A);
$is_wc = strpos($first_tool_full['category'], 'WooCommerce') === 0;
// Calculate new token totals
if ($is_wc) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$total_tokens = $wpdb->get_var($wpdb->prepare(
"SELECT COALESCE(SUM(token_estimate),0) FROM $table WHERE category LIKE %s AND enabled = %d",
'WooCommerce%', 1
));
} else {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$total_tokens = $wpdb->get_var($wpdb->prepare(
"SELECT COALESCE(SUM(token_estimate),0) FROM $table WHERE category NOT LIKE %s AND enabled = %d",
'WooCommerce%', 1
));
}
wp_send_json_success(array(
'enabled' => $new_status,
'total_tokens' => number_format_i18n(intval($total_tokens))
));
}
/**
* Render Abilities Tab - WordPress 6.9+ Abilities API Integration
*/
private function renderAbilitiesTab() {
?>
renderImportedAbilitiesList(); ?>
prefix . 'sflmcp_abilities';
// Check if table exists
$like = $wpdb->esc_like($table);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- schema check.
if ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $like)) !== $table) {
echo '
' . esc_html__('Abilities table not initialized. Please deactivate and reactivate the plugin.', 'stifli-flex-mcp') . '
';
return;
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- fresh data needed.
$abilities = $wpdb->get_results("SELECT * FROM {$table} ORDER BY ability_category, ability_label");
if (empty($abilities)) {
echo '
' . esc_html__('No abilities imported yet. Use the Discover button to find and import abilities.', 'stifli-flex-mcp') . '
';
return;
}
echo '
';
echo '
';
echo '';
echo ' ';
echo '' . esc_html__('Ability', 'stifli-flex-mcp') . ' ';
echo '' . esc_html__('Category', 'stifli-flex-mcp') . ' ';
echo '' . esc_html__('Enabled', 'stifli-flex-mcp') . ' ';
echo '' . esc_html__('Actions', 'stifli-flex-mcp') . ' ';
echo ' ';
foreach ($abilities as $ability) {
$enabled_class = $ability->enabled ? 'dashicons-yes-alt' : 'dashicons-marker';
$enabled_color = $ability->enabled ? '#46b450' : '#dc3232';
$tool_name = 'ability_' . str_replace(array('/', '-'), '_', $ability->ability_name);
/* translators: %s: ability label */
$select_ability_aria = sprintf( __( 'Select %s', 'stifli-flex-mcp' ), $ability->ability_label );
echo '';
echo '';
echo ' ';
echo ' ';
echo '';
echo '' . esc_html($ability->ability_label) . ' ';
echo '' . esc_html($tool_name) . '';
if (!empty($ability->ability_description)) {
echo '' . esc_html(wp_trim_words($ability->ability_description, 15)) . ' ';
}
echo ' ';
echo '' . esc_html($ability->ability_category) . ' ';
echo '';
echo '';
echo ' ';
echo ' ';
echo ' ';
echo '';
echo '';
echo ' ';
echo ' ';
echo ' ';
echo ' ';
}
echo '
';
}
/**
* Normalize an ability category label.
*
* @param mixed $ability_or_category Ability instance or raw category value.
* @return string
*/
private function getAbilityCategoryLabel( $ability_or_category ) {
$category = $ability_or_category;
if ( is_object( $ability_or_category ) && method_exists( $ability_or_category, 'get_category' ) ) {
$category = $ability_or_category->get_category();
}
if ( is_object( $category ) && method_exists( $category, 'get_label' ) ) {
$category = $category->get_label();
}
if ( is_scalar( $category ) ) {
$category = sanitize_text_field( (string) $category );
} else {
$category = '';
}
return '' !== $category ? $category : __( 'Uncategorized', 'stifli-flex-mcp' );
}
/**
* Import a single registered WordPress ability into the plugin table.
*
* @param string $ability_name Ability slug.
* @return int|WP_Error
*/
private function importAbilityRecord( $ability_name ) {
if ( ! stifli_flex_mcp_abilities_available() ) {
return new WP_Error( 'abilities_unavailable', __( 'WordPress Abilities API not available', 'stifli-flex-mcp' ) );
}
$ability = wp_get_ability( $ability_name );
if ( ! $ability ) {
return new WP_Error( 'ability_not_found', __( 'Ability not found', 'stifli-flex-mcp' ) );
}
global $wpdb;
$table = $wpdb->prefix . 'sflmcp_abilities';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$exists = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$table} WHERE ability_name = %s", $ability_name ) );
if ( $exists ) {
return new WP_Error( 'ability_exists', __( 'Ability already imported', 'stifli-flex-mcp' ) );
}
$input_schema = StifliFlexMcpUtils::normalizeToolInputSchema( $ability->get_input_schema() );
$output_schema = $ability->get_output_schema();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$result = $wpdb->insert(
$table,
array(
'ability_name' => $ability_name,
'ability_label' => $ability->get_label(),
'ability_description' => $ability->get_description(),
'ability_category' => $this->getAbilityCategoryLabel( $ability ),
'input_schema' => is_array( $input_schema ) ? wp_json_encode( $input_schema ) : null,
'output_schema' => is_array( $output_schema ) ? wp_json_encode( $output_schema ) : null,
'enabled' => 1,
'created_at' => current_time( 'mysql', true ),
'updated_at' => current_time( 'mysql', true ),
),
array( '%s', '%s', '%s', '%s', '%s', '%s', '%d', '%s', '%s' )
);
if ( false === $result ) {
return new WP_Error( 'ability_insert_failed', __( 'Failed to import ability', 'stifli-flex-mcp' ) );
}
return (int) $wpdb->insert_id;
}
/**
* AJAX handler: Discover available WordPress Abilities
*/
public function ajax_discover_abilities() {
check_ajax_referer('sflmcp_abilities', 'nonce');
if (!current_user_can('manage_options')) {
wp_send_json_error(array('message' => __('Permission denied', 'stifli-flex-mcp')));
}
if (!stifli_flex_mcp_abilities_available()) {
wp_send_json_error(array('message' => __('WordPress Abilities API not available. Requires WordPress 6.9+', 'stifli-flex-mcp')));
}
// Get all registered abilities using wp_get_abilities()
$all_abilities = wp_get_abilities();
if (empty($all_abilities)) {
wp_send_json_success(array(
'abilities' => array(),
'message' => __('No abilities found. Install plugins that register WordPress Abilities.', 'stifli-flex-mcp'),
));
return;
}
// Get already imported abilities
global $wpdb;
$table = $wpdb->prefix . 'sflmcp_abilities';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$imported = $wpdb->get_col("SELECT ability_name FROM {$table}");
$imported_map = array_flip($imported);
$abilities_list = array();
foreach ($all_abilities as $ability) {
$name = $ability->get_name();
// Skip our own abilities if we ever register any
if (strpos($name, 'sflmcp/') === 0) {
continue;
}
// Get category - may be a string or null
$abilities_list[] = array(
'name' => $name,
'label' => $ability->get_label(),
'description' => $ability->get_description(),
'category' => $this->getAbilityCategoryLabel( $ability ),
'input_schema' => $ability->get_input_schema(),
'output_schema' => method_exists($ability, 'get_output_schema') ? $ability->get_output_schema() : null,
'imported' => isset($imported_map[$name]),
);
}
wp_send_json_success(array(
'abilities' => $abilities_list,
'count' => count($abilities_list),
));
}
/**
* AJAX handler: Import an ability
*/
public function ajax_import_ability() {
check_ajax_referer('sflmcp_abilities', 'nonce');
if (!current_user_can('manage_options')) {
wp_send_json_error(array('message' => __('Permission denied', 'stifli-flex-mcp')));
}
$ability_name = isset($_POST['ability_name']) ? sanitize_text_field(wp_unslash($_POST['ability_name'])) : '';
if (empty($ability_name)) {
wp_send_json_error(array('message' => __('Ability name is required', 'stifli-flex-mcp')));
}
if (!stifli_flex_mcp_abilities_available()) {
wp_send_json_error(array('message' => __('WordPress Abilities API not available', 'stifli-flex-mcp')));
}
$result = $this->importAbilityRecord( $ability_name );
if ( is_wp_error( $result ) ) {
wp_send_json_error(array('message' => $result->get_error_message()));
}
wp_send_json_success(array(
'message' => __('Ability imported successfully', 'stifli-flex-mcp'),
'id' => $result,
));
}
/**
* AJAX handler: Bulk manage imported or discovered abilities.
*/
public function ajax_bulk_manage_abilities() {
check_ajax_referer( 'sflmcp_abilities', 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( array( 'message' => __( 'Permission denied', 'stifli-flex-mcp' ) ) );
}
$bulk_action = isset( $_POST['bulk_action'] ) ? sanitize_key( wp_unslash( $_POST['bulk_action'] ) ) : '';
if ( empty( $bulk_action ) ) {
wp_send_json_error( array( 'message' => __( 'Bulk action is required', 'stifli-flex-mcp' ) ) );
}
$ability_ids = array();
if ( isset( $_POST['ability_ids'] ) ) {
$ability_ids = array_values( array_unique( array_filter( array_map( 'intval', (array) wp_unslash( $_POST['ability_ids'] ) ) ) ) );
}
$ability_names = array();
if ( isset( $_POST['ability_names'] ) ) {
$ability_names = array_values( array_unique( array_filter( array_map( 'sanitize_text_field', (array) wp_unslash( $_POST['ability_names'] ) ) ) ) );
}
global $wpdb;
$table = $wpdb->prefix . 'sflmcp_abilities';
switch ( $bulk_action ) {
case 'enable':
case 'disable':
if ( empty( $ability_ids ) ) {
wp_send_json_error( array( 'message' => __( 'Select at least one imported ability.', 'stifli-flex-mcp' ) ) );
}
$new_state = 'enable' === $bulk_action ? 1 : 0;
$updated = 0;
$timestamp = current_time( 'mysql', true );
foreach ( $ability_ids as $ability_id ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$row_result = $wpdb->update(
$table,
array(
'enabled' => $new_state,
'updated_at' => $timestamp,
),
array( 'id' => $ability_id ),
array( '%d', '%s' ),
array( '%d' )
);
if ( false === $row_result ) {
wp_send_json_error( array( 'message' => __( 'Failed to update abilities', 'stifli-flex-mcp' ) ) );
}
$updated += (int) $row_result;
}
wp_send_json_success( array(
'updated' => (int) $updated,
'message' => 'enable' === $bulk_action
? __( 'Selected abilities enabled', 'stifli-flex-mcp' )
: __( 'Selected abilities disabled', 'stifli-flex-mcp' ),
) );
break;
case 'delete':
if ( empty( $ability_ids ) ) {
wp_send_json_error( array( 'message' => __( 'Select at least one imported ability.', 'stifli-flex-mcp' ) ) );
}
$deleted = 0;
foreach ( $ability_ids as $ability_id ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$row_result = $wpdb->delete( $table, array( 'id' => $ability_id ), array( '%d' ) );
if ( false === $row_result ) {
wp_send_json_error( array( 'message' => __( 'Failed to delete abilities', 'stifli-flex-mcp' ) ) );
}
$deleted += (int) $row_result;
}
wp_send_json_success( array(
'deleted' => (int) $deleted,
'message' => __( 'Selected abilities removed', 'stifli-flex-mcp' ),
) );
break;
case 'import':
if ( empty( $ability_names ) ) {
wp_send_json_error( array( 'message' => __( 'Select at least one discovered ability.', 'stifli-flex-mcp' ) ) );
}
$imported_count = 0;
$skipped_count = 0;
$processed_names = array();
$errors = array();
foreach ( $ability_names as $ability_name ) {
$result = $this->importAbilityRecord( $ability_name );
if ( is_wp_error( $result ) ) {
if ( 'ability_exists' === $result->get_error_code() ) {
++$skipped_count;
$processed_names[] = $ability_name;
continue;
}
$errors[] = $result->get_error_message();
continue;
}
++$imported_count;
$processed_names[] = $ability_name;
}
if ( 0 === $imported_count && ! empty( $errors ) ) {
wp_send_json_error( array( 'message' => $errors[0] ) );
}
/* translators: %d: number of imported abilities. */
$imported_message = sprintf( __( '%d abilities imported', 'stifli-flex-mcp' ), $imported_count );
$message = $imported_count > 0
? $imported_message
: __( 'No new abilities were imported', 'stifli-flex-mcp' );
wp_send_json_success( array(
'imported' => $imported_count,
'skipped' => $skipped_count,
'processed_names' => $processed_names,
'errors' => $errors,
'message' => $message,
) );
break;
default:
wp_send_json_error( array( 'message' => __( 'Invalid bulk action', 'stifli-flex-mcp' ) ) );
}
}
/**
* AJAX handler: Toggle ability enabled state
*/
public function ajax_toggle_ability() {
check_ajax_referer('sflmcp_abilities', 'nonce');
if (!current_user_can('manage_options')) {
wp_send_json_error(array('message' => __('Permission denied', 'stifli-flex-mcp')));
}
$ability_id = isset($_POST['ability_id']) ? intval($_POST['ability_id']) : 0;
if ($ability_id <= 0) {
wp_send_json_error(array('message' => __('Invalid ability ID', 'stifli-flex-mcp')));
}
global $wpdb;
$table = $wpdb->prefix . 'sflmcp_abilities';
// Get current state
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$current = $wpdb->get_var($wpdb->prepare("SELECT enabled FROM {$table} WHERE id = %d", $ability_id));
if ($current === null) {
wp_send_json_error(array('message' => __('Ability not found', 'stifli-flex-mcp')));
}
$new_state = $current ? 0 : 1;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->update(
$table,
array('enabled' => $new_state, 'updated_at' => current_time('mysql', true)),
array('id' => $ability_id),
array('%d', '%s'),
array('%d')
);
wp_send_json_success(array(
'enabled' => $new_state,
'message' => $new_state ? __('Ability enabled', 'stifli-flex-mcp') : __('Ability disabled', 'stifli-flex-mcp'),
));
}
/**
* AJAX handler: Delete an imported ability
*/
public function ajax_delete_ability() {
check_ajax_referer('sflmcp_abilities', 'nonce');
if (!current_user_can('manage_options')) {
wp_send_json_error(array('message' => __('Permission denied', 'stifli-flex-mcp')));
}
$ability_id = isset($_POST['ability_id']) ? intval($_POST['ability_id']) : 0;
if ($ability_id <= 0) {
wp_send_json_error(array('message' => __('Invalid ability ID', 'stifli-flex-mcp')));
}
global $wpdb;
$table = $wpdb->prefix . 'sflmcp_abilities';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$result = $wpdb->delete($table, array('id' => $ability_id), array('%d'));
if ($result === false) {
wp_send_json_error(array('message' => __('Failed to delete ability', 'stifli-flex-mcp')));
}
wp_send_json_success(array('message' => __('Ability removed', 'stifli-flex-mcp')));
}
/**
* AJAX handler: Get imported abilities list (for refresh)
*/
public function ajax_get_imported_abilities() {
check_ajax_referer('sflmcp_abilities', 'nonce');
if (!current_user_can('manage_options')) {
wp_send_json_error(array('message' => __('Permission denied', 'stifli-flex-mcp')));
}
ob_start();
$this->renderImportedAbilitiesList();
$html = ob_get_clean();
wp_send_json_success(array('html' => $html));
}
/**
* Enqueue assets for the Multimedia submenu page.
*/
private function enqueueMultimediaAssets() {
wp_enqueue_style(
'sflmcp-admin-multimedia',
plugin_dir_url(__FILE__) . 'assets/admin-multimedia.css',
array(),
'1.3.0'
);
wp_enqueue_script(
'sflmcp-admin-multimedia',
plugin_dir_url(__FILE__) . 'assets/admin-multimedia.js',
array('jquery'),
'1.6.0',
true
);
wp_localize_script('sflmcp-admin-multimedia', 'sflmcpMultimedia', array(
'ajaxUrl' => admin_url('admin-ajax.php'),
'nonce' => wp_create_nonce('sflmcp_multimedia'),
'i18n' => array(
'saving' => __('Saving...', 'stifli-flex-mcp'),
'saved' => __('Saved', 'stifli-flex-mcp'),
'error' => __('Error saving settings', 'stifli-flex-mcp'),
'loaded' => __('Settings loaded', 'stifli-flex-mcp'),
'enabled' => __('Enabled', 'stifli-flex-mcp'),
'disabled' => __('Disabled', 'stifli-flex-mcp'),
),
));
}
/**
* Render the Multimedia admin page (standalone submenu).
*/
public function multimediaPage() {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'You do not have permission to view this page.', 'stifli-flex-mcp' ) );
}
$active_tab = isset( $_GET['tab'] ) ? sanitize_text_field( wp_unslash( $_GET['tab'] ) ) : 'images'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
?>
renderVideoSettingsTab();
} else {
$this->renderMultimediaTab();
}
?>
'openai',
// OpenAI image settings
'openai_api_key' => '',
'openai_model' => 'gpt-image-1',
'openai_quality' => 'medium',
'openai_size' => 'square',
'openai_style' => 'natural',
'openai_background' => 'auto',
'openai_output_format' => 'png',
// Gemini image settings
'gemini_api_key' => '',
'gemini_model' => 'gemini-2.5-flash-image',
'gemini_aspect_ratio' => '1:1',
// Post-processing
'pp_enabled' => '1',
'pp_max_width' => 1024,
'pp_max_height' => 1024,
'pp_quality' => 80,
'pp_format' => 'original',
// Video settings
'video_provider' => 'gemini',
'video_gemini_model' => 'veo-3.0-generate-preview',
'video_openai_model' => 'sora-2',
'video_duration' => '5',
'video_aspect_ratio' => '16:9',
'video_resolution' => '720p',
'video_poll_interval' => 10,
'video_max_wait' => 300,
);
$saved = get_option( 'sflmcp_multimedia_settings', array() );
return wp_parse_args( $saved, $defaults );
}
/**
* Create a partial display mask for an encrypted API key.
*
* Decrypts the stored key, then returns a masked version showing the first
* few characters and last 4, with bullets in between matching the real length.
* Example: "sk-proj-••••••••••••••••abcd"
*
* @param string $encrypted_value The encrypted (or empty) key from settings.
* @return string Partial mask for display, or empty string if no key stored.
*/
private function maskApiKeyForDisplay( $encrypted_value ) {
if ( empty( $encrypted_value ) ) {
return '';
}
// Decrypt to get real key
$plain = '';
if ( class_exists( 'StifliFlexMcp_Client_Admin' ) ) {
$plain = StifliFlexMcp_Client_Admin::decrypt_value( $encrypted_value );
} else {
$plain = $encrypted_value;
}
if ( empty( $plain ) ) {
return '';
}
$len = strlen( $plain );
// Very short keys: just show bullets
if ( $len <= 8 ) {
return str_repeat( '•', $len );
}
// Show first 4 chars + bullets + last 4 chars
$prefix = substr( $plain, 0, 4 );
$suffix = substr( $plain, -4 );
$mid_count = max( 4, $len - 8 );
return $prefix . str_repeat( '•', $mid_count ) . $suffix;
}
/**
* Render Multimedia Settings Tab
*/
private function renderMultimediaTab() {
$s = $this->getMultimediaSettings();
$has_gd = extension_loaded( 'gd' );
$gd_info = $has_gd ? gd_info() : array();
// Build partial display strings for API keys (e.g. "sk-••••••••xxxx")
$openai_display = $this->maskApiKeyForDisplay( $s['openai_api_key'] );
$gemini_display = $this->maskApiKeyForDisplay( $s['gemini_api_key'] );
?>
getMultimediaSettings();
// Build partial display strings for API keys (same shared keys as image tab)
$openai_display = $this->maskApiKeyForDisplay( $s['openai_api_key'] );
$gemini_display = $this->maskApiKeyForDisplay( $s['gemini_api_key'] );
?>
__( 'Permission denied', 'stifli-flex-mcp' ) ) );
}
// Allowed values for enum fields
$allowed_enums = array(
'image_provider' => array( 'openai', 'gemini' ),
'openai_model' => array( 'gpt-image-2', 'gpt-image-1.5', 'gpt-image-1', 'gpt-image-1-mini', 'dall-e-3', 'dall-e-2' ),
'openai_quality' => array( 'low', 'medium', 'high' ),
'openai_size' => array( 'square', 'landscape', 'portrait' ),
'openai_style' => array( 'natural', 'vivid' ),
'openai_background' => array( 'auto', 'transparent', 'opaque' ),
'openai_output_format' => array( 'png', 'jpeg', 'webp' ),
'gemini_model' => array( 'gemini-3.1-flash-image-preview', 'gemini-3-pro-image-preview', 'gemini-2.5-flash-image', 'imagen-4.0-generate-001', 'imagen-4.0-fast-generate-001', 'imagen-4.0-ultra-generate-001' ),
'gemini_aspect_ratio' => array( '1:1', '16:9', '9:16', '4:3', '3:4', '3:2', '2:3' ),
'pp_format' => array( 'original', 'jpeg', 'webp', 'png' ),
'video_provider' => array( 'gemini', 'openai' ),
'video_gemini_model' => array( 'veo-3.0-generate-preview', 'veo-2.0-generate-001' ),
'video_openai_model' => array( 'sora-2', 'sora-2-pro' ),
'video_duration' => array( '4', '5', '6', '8', '12' ),
'video_aspect_ratio' => array( '16:9', '9:16', '1:1' ),
'video_resolution' => array( '480p', '720p', '1080p' ),
);
// Numeric fields: key => array( min, max )
$numeric_fields = array(
'pp_max_width' => array( 0, 4096 ),
'pp_max_height' => array( 0, 4096 ),
'pp_quality' => array( 30, 100 ),
'video_poll_interval' => array( 5, 60 ),
'video_max_wait' => array( 60, 600 ),
);
// Start from existing settings (partial merge — only update fields present in POST)
$settings = get_option( 'sflmcp_multimedia_settings', array() );
// phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce verified above via check_ajax_referer
// Update enum fields only if present in POST
foreach ( $allowed_enums as $key => $values ) {
if ( isset( $_POST[ $key ] ) ) {
$val = sanitize_text_field( wp_unslash( $_POST[ $key ] ) );
if ( in_array( $val, $values, true ) ) {
$settings[ $key ] = $val;
}
}
}
// Update checkbox field
if ( isset( $_POST['pp_enabled'] ) ) {
$settings['pp_enabled'] = sanitize_text_field( wp_unslash( $_POST['pp_enabled'] ) ) === '1' ? '1' : '0';
}
// Update numeric fields only if present in POST
foreach ( $numeric_fields as $key => $range ) {
if ( isset( $_POST[ $key ] ) ) {
$settings[ $key ] = max( $range[0], min( $range[1], intval( $_POST[ $key ] ) ) );
}
}
// Handle API keys — only update if user entered a real value (not the masked placeholder)
$api_key_fields = array( 'openai_api_key', 'gemini_api_key' );
foreach ( $api_key_fields as $key ) {
if ( isset( $_POST[ $key ] ) ) {
$raw = sanitize_text_field( wp_unslash( $_POST[ $key ] ) );
// Skip if empty or contains bullet chars (masked value, not a real new key)
if ( ! empty( $raw ) && strpos( $raw, '•' ) === false ) {
if ( class_exists( 'StifliFlexMcp_Client_Admin' ) ) {
$ref = new ReflectionMethod( 'StifliFlexMcp_Client_Admin', 'encrypt_value' );
$ref->setAccessible( true );
$settings[ $key ] = $ref->invoke( null, $raw );
} else {
$settings[ $key ] = $raw;
}
}
// If empty or masked, keep existing value — do not touch $settings[$key]
}
}
// phpcs:enable WordPress.Security.NonceVerification.Missing
update_option( 'sflmcp_multimedia_settings', $settings );
wp_send_json_success( array( 'message' => __( 'Settings saved', 'stifli-flex-mcp' ) ) );
}
/**
* AJAX handler: Load multimedia settings.
*/
public function ajax_load_multimedia_settings() {
check_ajax_referer( 'sflmcp_multimedia', 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( array( 'message' => __( 'Permission denied', 'stifli-flex-mcp' ) ) );
}
$s = $this->getMultimediaSettings();
// Mask API keys — partial reveal (first 4 + bullets + last 4 chars)
$api_keys = array( 'openai_api_key', 'gemini_api_key' );
foreach ( $api_keys as $key ) {
$s[ $key ] = $this->maskApiKeyForDisplay( $s[ $key ] );
}
// Include tool enabled/disabled status from wp_sflmcp_tools.
global $wpdb;
$tools_table = $wpdb->prefix . 'sflmcp_tools';
$tool_names = array( 'wp_generate_image', 'wp_generate_video' );
foreach ( $tool_names as $tname ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$enabled = $wpdb->get_var( $wpdb->prepare(
"SELECT enabled FROM {$tools_table} WHERE tool_name = %s LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$tname
) );
$s[ 'tool_enabled_' . $tname ] = ( '1' === $enabled || 1 === (int) $enabled ) ? '1' : '0';
}
wp_send_json_success( $s );
}
/**
* AJAX handler: Reveal a full decrypted API key (admin only).
*/
public function ajax_mm_reveal_key() {
check_ajax_referer( 'sflmcp_multimedia', 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( array( 'message' => __( 'Permission denied', 'stifli-flex-mcp' ) ) );
}
$key_name = sanitize_text_field( wp_unslash( $_POST['key_name'] ?? '' ) );
$allowed = array( 'openai_api_key', 'gemini_api_key' );
if ( ! in_array( $key_name, $allowed, true ) ) {
wp_send_json_error( array( 'message' => __( 'Invalid key name', 'stifli-flex-mcp' ) ) );
}
$settings = $this->getMultimediaSettings();
$encrypted = isset( $settings[ $key_name ] ) ? $settings[ $key_name ] : '';
if ( empty( $encrypted ) ) {
wp_send_json_success( array( 'key' => '' ) );
}
$plain = '';
if ( class_exists( 'StifliFlexMcp_Client_Admin' ) ) {
$plain = StifliFlexMcp_Client_Admin::decrypt_value( $encrypted );
} else {
$plain = $encrypted;
}
wp_send_json_success( array( 'key' => $plain ) );
}
/**
* AJAX handler: Toggle a multimedia tool on/off by tool_name.
*/
public function ajax_mm_toggle_tool() {
check_ajax_referer( 'sflmcp_multimedia', 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( array( 'message' => __( 'Permission denied', 'stifli-flex-mcp' ) ) );
}
$tool_name = sanitize_text_field( wp_unslash( $_POST['tool_name'] ?? '' ) );
$enabled = isset( $_POST['enabled'] ) ? intval( $_POST['enabled'] ) : -1;
$allowed = array( 'wp_generate_image', 'wp_generate_video' );
if ( ! in_array( $tool_name, $allowed, true ) || ! in_array( $enabled, array( 0, 1 ), true ) ) {
wp_send_json_error( array( 'message' => __( 'Invalid parameters', 'stifli-flex-mcp' ) ) );
}
global $wpdb;
$tools_table = $wpdb->prefix . 'sflmcp_tools';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->update(
$tools_table,
array( 'enabled' => $enabled, 'updated_at' => current_time( 'mysql', true ) ),
array( 'tool_name' => $tool_name ),
array( '%d', '%s' ),
array( '%s' )
);
// Sync to active profile.
$this->syncToolToActiveProfile( $tool_name, $enabled );
wp_send_json_success( array( 'tool_name' => $tool_name, 'enabled' => $enabled ) );
}
// ================================================================
// OAuth Clients Tab
// ================================================================
/**
* Render the OAuth Clients admin tab.
*/
private function renderOAuthClientsTab() {
if ( ! class_exists( 'StifliFlexMcp_OAuth_Storage' ) ) {
echo '
' . esc_html__( 'OAuth module is not loaded.', 'stifli-flex-mcp' ) . '
';
return;
}
$storage = StifliFlexMcp_OAuth_Storage::get_instance();
$clients = $storage->get_all_clients();
$auto_approve = get_option( 'sflmcp_oauth_auto_approve', '1' );
// Preload token counts per client.
global $wpdb;
$tokens_table = $wpdb->prefix . 'sflmcp_oauth_tokens';
$now = gmdate( 'Y-m-d H:i:s' );
$token_counts = array();
$token_data = array();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$counts_raw = $wpdb->get_results(
$wpdb->prepare(
"SELECT client_id, COUNT(*) as cnt FROM {$tokens_table} WHERE revoked = 0 AND access_expires_at > %s GROUP BY client_id",
$now
)
);
foreach ( $counts_raw as $row ) {
$token_counts[ $row->client_id ] = (int) $row->cnt;
}
// Get active tokens grouped by client for expandable detail.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$tokens_raw = $wpdb->get_results(
$wpdb->prepare(
"SELECT t.id, t.client_id, t.user_id, t.scope, t.access_expires_at, t.created_at, u.display_name as user_name
FROM {$tokens_table} t
LEFT JOIN {$wpdb->users} u ON t.user_id = u.ID
WHERE t.revoked = 0 AND t.access_expires_at > %s
ORDER BY t.created_at DESC",
$now
)
);
foreach ( $tokens_raw as $tok ) {
$token_data[ $tok->client_id ][] = $tok;
}
?>
client_id );
$count = isset( $token_counts[ $client->client_id ] ) ? $token_counts[ $client->client_id ] : 0;
$is_public = ( 'none' === $client->token_endpoint_auth_method );
$tokens_list = isset( $token_data[ $client->client_id ] ) ? $token_data[ $client->client_id ] : array();
?>
client_name ); ?>
client_uri ) : ?>
client_uri ); ?>
client_id ); ?>
0 ) : ?>
0
created_at );
echo esc_html( date_i18n( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $registered + ( get_option( 'gmt_offset' ) * HOUR_IN_SECONDS ) ) );
?>
created_at );
$expires = strtotime( $tok->access_expires_at );
$offset = get_option( 'gmt_offset' ) * HOUR_IN_SECONDS;
?>
user_name ?: '#' . $tok->user_id ); ?>
scope ); ?>
🟣
' . ( PHP_OS_FAMILY === 'Darwin'
? '~/Library/Application Support/Claude/claude_desktop_config.json'
: '%APPDATA%\\Claude\\claude_desktop_config.json' ) . ''
),
array( 'code' => array() )
); ?>
{
"mcpServers": {
"": {
"type": "sse",
"url": "namespace . '/sse' ) ); ?>"
}
}
}
' . esc_html( rest_url( $this->namespace . '/sse' ) ) . ''
),
array( 'code' => array() )
); ?>
🟢
token_endpoint_auth_method: "client_secret_post"
redirect_uris: ["https://chatgpt.com/aip/YOUR_PLUGIN_ID /oauth/callback"]
namespace . '/oauth/token' ) ); ?>
mcp
🔑
'
. esc_html__( 'your profile', 'stifli-flex-mcp' ) . ''
),
array( 'a' => array( 'href' => array() ) )
); ?>
namespace . '/messages' );
$current_user = wp_get_current_user();
$example_user = ( $current_user && ! empty( $current_user->user_login ) ) ? $current_user->user_login : 'your_wp_username';
$curl_example = implode(
"\n",
array(
sprintf( 'curl -u "%s:
" \\', $example_user ),
' -H "Content-Type: application/json" \\',
' -d \'{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}\' \\',
sprintf( ' "%s"', $messages_endpoint ),
)
);
?>
' . esc_html( $messages_endpoint ) . ''
),
array( 'code' => array() )
); ?>
__( 'Permission denied', 'stifli-flex-mcp' ) ) );
return;
}
$client_id = isset( $_POST['client_id'] ) ? sanitize_text_field( wp_unslash( $_POST['client_id'] ) ) : '';
if ( empty( $client_id ) ) {
wp_send_json_error( array( 'message' => __( 'Missing client_id', 'stifli-flex-mcp' ) ) );
return;
}
$storage = StifliFlexMcp_OAuth_Storage::get_instance();
$deleted = $storage->delete_client( $client_id );
if ( $deleted ) {
wp_send_json_success();
} else {
wp_send_json_error( array( 'message' => __( 'Client not found', 'stifli-flex-mcp' ) ) );
}
}
/**
* AJAX: Revoke a specific OAuth token by row ID.
*/
public function ajax_oauth_revoke_token() {
check_ajax_referer( 'sflmcp_oauth', 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( array( 'message' => __( 'Permission denied', 'stifli-flex-mcp' ) ) );
return;
}
$token_id = isset( $_POST['token_id'] ) ? intval( $_POST['token_id'] ) : 0;
if ( ! $token_id ) {
wp_send_json_error( array( 'message' => __( 'Missing token_id', 'stifli-flex-mcp' ) ) );
return;
}
global $wpdb;
$table = $wpdb->prefix . 'sflmcp_oauth_tokens';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$updated = $wpdb->update(
$table,
array( 'revoked' => 1 ),
array( 'id' => $token_id ),
array( '%d' ),
array( '%d' )
);
if ( false !== $updated ) {
wp_send_json_success();
} else {
wp_send_json_error( array( 'message' => __( 'Token not found', 'stifli-flex-mcp' ) ) );
}
}
/**
* AJAX: Save OAuth settings (auto-approve toggle).
*/
public function ajax_oauth_save_settings() {
check_ajax_referer( 'sflmcp_oauth', 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( array( 'message' => __( 'Permission denied', 'stifli-flex-mcp' ) ) );
return;
}
$auto_approve = isset( $_POST['auto_approve'] ) ? sanitize_text_field( wp_unslash( $_POST['auto_approve'] ) ) : '0';
update_option( 'sflmcp_oauth_auto_approve', $auto_approve === '1' ? '1' : '0' );
$this->invalidateOAuthWellKnownProbeCache();
wp_send_json_success();
}
/**
* AJAX: Reset all OAuth clients, tokens, and auth codes.
*/
public function ajax_oauth_reset_state() {
check_ajax_referer( 'sflmcp_oauth', 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( array( 'message' => __( 'Permission denied', 'stifli-flex-mcp' ) ) );
return;
}
$storage = StifliFlexMcp_OAuth_Storage::get_instance();
$result = $storage->reset_all_state();
if ( is_wp_error( $result ) ) {
wp_send_json_error( array( 'message' => $result->get_error_message() ) );
return;
}
wp_send_json_success( $result );
}
/**
* AJAX: Generate an Application Password for current user.
*/
public function ajax_generate_app_password() {
check_ajax_referer( 'SFLMCP-admin', 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( array( 'message' => __( 'Permission denied', 'stifli-flex-mcp' ) ), 403 );
return;
}
if ( ! class_exists( 'WP_Application_Passwords' ) ) {
wp_send_json_error( array( 'message' => __( 'Application Passwords are not available on this WordPress installation.', 'stifli-flex-mcp' ) ) );
return;
}
$current_user = wp_get_current_user();
if ( ! is_a( $current_user, 'WP_User' ) || empty( $current_user->ID ) ) {
wp_send_json_error( array( 'message' => __( 'Could not resolve the current user.', 'stifli-flex-mcp' ) ) );
return;
}
if ( ! $this->isApplicationPasswordsAvailableForUser( $current_user ) ) {
wp_send_json_error( array( 'message' => __( 'Application Passwords are not available for this user/site. Ensure HTTPS is enabled and no plugin disables this feature.', 'stifli-flex-mcp' ) ) );
return;
}
$app_name = $this->getNextDefaultApplicationPasswordName( (int) $current_user->ID );
$created = WP_Application_Passwords::create_new_application_password(
(int) $current_user->ID,
array(
'name' => $app_name,
'app_id' => wp_generate_uuid4(),
)
);
if ( is_wp_error( $created ) ) {
wp_send_json_error( array( 'message' => wp_strip_all_tags( $created->get_error_message() ) ) );
return;
}
$raw_password = isset( $created[0] ) ? (string) $created[0] : '';
$record = ( isset( $created[1] ) && is_array( $created[1] ) ) ? $created[1] : array();
if ( '' === $raw_password ) {
wp_send_json_error( array( 'message' => __( 'Application Password was created but the plain-text value was not returned.', 'stifli-flex-mcp' ) ) );
return;
}
wp_send_json_success(
array(
'message' => __( 'Application Password created. Copy it now: it is only shown this one time.', 'stifli-flex-mcp' ),
'user_login' => isset( $current_user->user_login ) ? (string) $current_user->user_login : '',
'app_name' => isset( $record['name'] ) ? (string) $record['name'] : $app_name,
'password' => $raw_password,
)
);
}
/**
* Render Help Tab - Complete documentation
*/
private function renderHelpTab() {
$site_url = site_url();
$endpoint = rest_url($this->namespace . '/messages');
?>
📚
🎯
Posts & Pages wp_get_posts, wp_create_post, wp_update_post, wp_delete_post
Media wp_upload_image, wp_upload_image_from_url, wp_get_media
Taxonomies wp_get_categories, wp_create_tag, wp_get_terms
Users wp_get_users, wp_get_user_meta, wp_update_user_meta
WooCommerce wc_get_products, wc_create_order, wc_update_stock
System wp_get_site_health, wp_get_settings, mcp_ping
HTTP (GET/POST/PUT/DELETE)
HTTP
ACTION
ACTION
⚡
💡 :
flush_rewrite_rules
wp_cron
wp_cache_flush
woocommerce_cancel_unpaid_orders
woocommerce_cleanup_sessions
woocommerce_scheduled_sales
Yoast SEO wpseo_reindex
WP Super Cache wp_cache_clear_cache
W3 Total Cache w3tc_flush_all
WP Rocket rocket_clean_domain
Elementor elementor/core/files/clear_cache
🔍
"[plugin name] action hooks"
"[plugin name] do_action"
"[plugin name] developer documentation"
# In your plugin folder, search for hooks:
grep -r "do_action(" wp-content/plugins/your-plugin/
# Common patterns:
do_action('plugin_prefix_event_name');
do_action('plugin_prefix_before_save', $data);
do_action('plugin_prefix_after_delete', $id);
📋
: custom_clear_rocket_cache
: "Clear WP Rocket cache. Use when site shows outdated content."
: ACTION (WordPress do_action)
: rocket_clean_domain
: (none needed)
🌐
Zapier https://hooks.zapier.com/hooks/catch/...
Make (Integromat) https://hook.eu1.make.com/...
n8n https://your-n8n.com/webhook/...
IFTTT https://maker.ifttt.com/trigger/...
📋
Name: custom_create_jira_ticket
Type: POST
Endpoint: (your Zapier URL)
Parameters: title (string, required), description (string), priority (string)
wttr.in https://wttr.in/{city}?format=j1
ipapi.co https://ipapi.co/{ip}/json/
OpenAI https://api.openai.com/v1/...
🏠
/wp-json/wp/v2/posts
/wp-json/wp/v2/pages/{id}
/wp-json/wc/v3/products
/wp-json/contact-form-7/v1/contact-forms
⚠️
/wp-json/
💡
🛒
→ woocommerce_cancel_unpaid_orders
→ built-in wc_update_stock
→ built-in wc_create_coupon
🔧
→ w3tc_flush_all / rocket_clean_domain
→ sflmcp_maintenance_mode
→ flush_rewrite_rules
📊
→ Notion API webhook
→ Zapier/Make webhook
→ Twilio API
🔐
Authorization: Bearer your-api-token
Content-Type: application/json
X-Custom-Header: value
🔧