auth = $auth; $this->tools = $tools; } /** * Set the tools instance (resolves circular dependency). * * @param MCPTools $tools The tools instance. * * @return void */ public function set_tools( MCPTools $tools ): void { $this->tools = $tools; } /** * Handle an incoming MCP request. * * @param \WP_REST_Request $request The REST request. * * @return \WP_REST_Response JSON-RPC 2.0 response. */ public function handle_request( \WP_REST_Request $request ): \WP_REST_Response { $body = $request->get_json_params(); if ( ! $body || ! isset( $body['method'] ) ) { return $this->error_response( null, -32700, 'Parse error' ); } // Validate JSON-RPC 2.0 structure. if ( ! isset( $body['jsonrpc'] ) || '2.0' !== $body['jsonrpc'] ) { return $this->error_response( $body['id'] ?? null, -32600, 'Invalid request: missing or wrong jsonrpc version' ); } if ( ! isset( $body['id'] ) ) { return $this->error_response( null, -32600, 'Invalid request: id is required' ); } $id = (string) $body['id']; $method = $body['method']; $params = $body['params'] ?? array(); // Log the request asynchronously. $this->log_request( $request, $method, $params ); return match ( $method ) { 'initialize' => $this->handle_initialize( $id, $params ), 'tools/list' => $this->handle_tools_list( $id ), 'tools/call' => $this->handle_tools_call( $id, $params ), 'ping' => $this->success_response( $id, array( 'status' => 'ok' ) ), default => $this->error_response( $id, -32601, 'Method not found: ' . esc_html( $method ) ), }; } /** * Handle the initialize handshake. * * @param string $id Request ID. * @param array $params Request params. * * @return \WP_REST_Response */ private function handle_initialize( string $id, array $params ): \WP_REST_Response { $result = array( 'protocolVersion' => self::PROTOCOL_VERSION, 'serverInfo' => array( 'name' => 'Tyralix AI Discoverability MCP Server', 'version' => defined( 'AICOMMERCE_VERSION' ) ? AICOMMERCE_VERSION : '2.0.0', ), 'capabilities' => array( 'tools' => array( 'listChanged' => false ), ), 'storeInfo' => array( 'name' => get_bloginfo( 'name' ), 'url' => home_url(), 'currency' => function_exists( 'get_woocommerce_currency' ) ? get_woocommerce_currency() : 'USD', 'locale' => get_locale(), ), ); return $this->success_response( $id, $result ); } /** * Handle tools/list — returns available tool definitions. * * @param string $id Request ID. * * @return \WP_REST_Response */ private function handle_tools_list( string $id ): \WP_REST_Response { $tools = $this->tools ? $this->tools->get_tool_definitions() : array(); return $this->success_response( $id, array( 'tools' => $tools ) ); } /** * Handle tools/call — execute a named tool. * * @param string $id Request ID. * @param array $params Request params containing 'name' and 'arguments'. * * @return \WP_REST_Response */ private function handle_tools_call( string $id, array $params ): \WP_REST_Response { $tool_name = $params['name'] ?? null; $arguments = $params['arguments'] ?? array(); if ( ! $tool_name ) { return $this->error_response( $id, -32602, 'Missing required parameter: name' ); } if ( ! $this->tools ) { return $this->error_response( $id, -32603, 'Internal error: tools not initialized' ); } return match ( $tool_name ) { 'search_products' => $this->tools->search_products( $id, $arguments ), 'get_product_details' => $this->tools->get_product_details( $id, $arguments ), 'check_inventory' => $this->tools->check_inventory( $id, $arguments ), 'get_price' => $this->tools->get_price( $id, $arguments ), 'get_categories' => $this->tools->get_categories( $id, $arguments ), 'get_store_info' => $this->tools->get_store_info( $id, $arguments ), 'initiate_checkout' => $this->tools->initiate_checkout( $id, $arguments ), 'get_checkout_status' => $this->tools->get_checkout_status( $id, $arguments ), default => $this->error_response( $id, -32601, 'Unknown tool: ' . esc_html( $tool_name ) ), }; } /** * Build a JSON-RPC 2.0 success response. * * @param string $id Request ID. * @param mixed $result Result payload. * * @return \WP_REST_Response */ public function success_response( string $id, $result ): \WP_REST_Response { return new \WP_REST_Response( array( 'jsonrpc' => '2.0', 'id' => $id, 'result' => $result, ), 200 ); } /** * Build a JSON-RPC 2.0 error response. * * @param string|null $id Request ID (null for parse errors). * @param int $code JSON-RPC error code. * @param string $message Error message. * * @return \WP_REST_Response */ public function error_response( ?string $id, int $code, string $message ): \WP_REST_Response { $http_status = match ( true ) { -32000 === $code => 401, -32001 === $code => 429, default => 200, }; return new \WP_REST_Response( array( 'jsonrpc' => '2.0', 'id' => $id, 'error' => array( 'code' => $code, 'message' => $message, ), ), $http_status ); } /** * Build a tool result response (MCP content block format). * * @param string $id Request ID. * @param array $content Tool result data. * * @return \WP_REST_Response */ public function tool_result( string $id, array $content ): \WP_REST_Response { return $this->success_response( $id, array( 'content' => array( array( 'type' => 'text', 'text' => wp_json_encode( $content ), ), ), ) ); } /** * Get the MCP schema/discovery information. * * @return \WP_REST_Response */ public function get_schema(): \WP_REST_Response { $tools = $this->tools ? $this->tools->get_tool_definitions() : array(); return new \WP_REST_Response( array( 'endpoint' => rest_url( 'aicommerce/v1/mcp' ), 'auth_modes' => array( get_option( 'aicommerce_mcp_auth_mode', 'api_key' ) ), 'tools_count' => count( $tools ), 'tools' => $tools, ), 200 ); } /** * Log an MCP request asynchronously. * * Uses the existing aicommerce_agent_log table. * * @param \WP_REST_Request $request The REST request. * @param string $method JSON-RPC method name. * @param array $params Request params. * * @return void */ private function log_request( \WP_REST_Request $request, string $method, array $params ): void { $auth_header = $request->get_header( 'authorization' ); $identity = 'public'; if ( $auth_header && str_starts_with( $auth_header, 'Bearer ' ) ) { $identity = substr( hash( 'sha256', substr( $auth_header, 7 ) ), 0, 12 ); } $tool_name = ''; if ( 'tools/call' === $method && isset( $params['name'] ) ) { $tool_name = sanitize_text_field( $params['name'] ); } // Sanitize arguments for logging — strip any sensitive data. $log_params = array(); if ( 'tools/call' === $method && isset( $params['arguments'] ) ) { $log_params = array_map( 'sanitize_text_field', array_filter( $params['arguments'], 'is_scalar' ) ); } $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '0.0.0.0'; wp_schedule_single_event( time(), 'aicommerce_log_mcp_request', array( array( 'agent_identity' => $identity, 'method' => $method, 'tool_name' => $tool_name, 'query_params' => wp_json_encode( $log_params ), 'ip_address' => $ip, 'request_time' => current_time( 'mysql' ), ), ) ); } /** * Write an MCP log entry to the database. * * Callback for the aicommerce_log_mcp_request cron action. * * @param array $data Log data. * * @return void */ public function write_log_entry( array $data ): void { global $wpdb; // Safe: internal $wpdb->prefix table names; user values use placeholders. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $wpdb->insert( $wpdb->prefix . 'aicommerce_agent_log', array( 'agent_id' => $data['agent_identity'] ?? 'unknown', 'agent_platform' => 'mcp', 'action' => $data['method'] ?? '', 'request_payload' => $data['query_params'] ?? '{}', 'status' => 'success', 'ip_address' => $data['ip_address'] ?? '', 'created_at' => $data['request_time'] ?? current_time( 'mysql' ), ), array( '%s', '%s', '%s', '%s', '%s', '%s', '%s' ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter } /** * Get agent activity statistics for the dashboard. * * @return \WP_REST_Response */ public function get_agent_stats(): \WP_REST_Response { global $wpdb; $table = $wpdb->prefix . 'aicommerce_agent_log'; // Today's request count. // Safe: internal $wpdb->prefix table names; user values use placeholders. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $today_count = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE agent_platform = %s AND created_at >= %s", 'mcp', current_time( 'Y-m-d' ) . ' 00:00:00' ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter // Active API keys count. $stored_keys = get_option( 'aicommerce_mcp_api_keys', array() ); $active_keys = count( $stored_keys ); // Top tool today. // Safe: internal $wpdb->prefix table names; user values use placeholders. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $top_tool = $wpdb->get_var( $wpdb->prepare( "SELECT request_payload FROM {$table} WHERE agent_platform = %s AND action = %s AND created_at >= %s GROUP BY request_payload ORDER BY COUNT(*) DESC LIMIT 1", 'mcp', 'tools/call', current_time( 'Y-m-d' ) . ' 00:00:00' ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter // Rate limit hits today (count transients is impractical, use 0 as default). $rate_limit_hits = 0; // Hourly breakdown for last 24 hours. $since = gmdate( 'Y-m-d H:i:s', time() - DAY_IN_SECONDS ); // Safe: internal $wpdb->prefix table names; user values use placeholders. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $hourly_rows = $wpdb->get_results( $wpdb->prepare( "SELECT HOUR(created_at) AS hour, action AS tool, COUNT(*) AS count FROM {$table} WHERE agent_platform = %s AND created_at >= %s GROUP BY HOUR(created_at), action ORDER BY hour", 'mcp', $since ), ARRAY_A ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter return new \WP_REST_Response( array( 'today_count' => $today_count, 'active_keys' => $active_keys, 'top_tool' => $top_tool ?: 'none', 'rate_limit_hits' => $rate_limit_hits, 'hourly' => $hourly_rows ?: array(), ), 200 ); } /** * Get paginated agent request log. * * @param \WP_REST_Request $request The REST request. * * @return \WP_REST_Response */ public function get_agent_log( \WP_REST_Request $request ): \WP_REST_Response { global $wpdb; $table = $wpdb->prefix . 'aicommerce_agent_log'; $per_page = min( 100, max( 1, (int) $request->get_param( 'per_page' ) ) ); $page = max( 1, (int) $request->get_param( 'page' ) ); $offset = ( $page - 1 ) * $per_page; $tool_name = sanitize_text_field( $request->get_param( 'tool_name' ) ?? '' ); $where = $wpdb->prepare( 'WHERE agent_platform = %s', 'mcp' ); if ( $tool_name ) { $where .= $wpdb->prepare( ' AND action = %s', $tool_name ); } // Safe: internal $wpdb->prefix table names; user values use placeholders. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table} {$where}" ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter // Safe: internal $wpdb->prefix table names; user values use placeholders. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $rows = $wpdb->get_results( $wpdb->prepare( "SELECT agent_id, action, request_payload, status, ip_address, created_at FROM {$table} {$where} ORDER BY created_at DESC LIMIT %d OFFSET %d", $per_page, $offset ), ARRAY_A ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter return new \WP_REST_Response( array( 'logs' => $rows ?: array(), 'total' => $total, 'page' => $page, 'pages' => (int) ceil( $total / $per_page ), ), 200 ); } /** * Get recent agent checkout sessions for the dashboard. * * @return \WP_REST_Response */ public function get_agent_sessions(): \WP_REST_Response { global $wpdb; $table = $wpdb->prefix . 'aicommerce_agent_sessions'; // Safe: internal $wpdb->prefix table names; user values use placeholders. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $rows = $wpdb->get_results( "SELECT session_id, agent_identity, status, cart_items, total_amount, currency, wc_order_id, created_at, expires_at FROM {$table} ORDER BY created_at DESC LIMIT 50", ARRAY_A ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $sessions = array(); foreach ( ( $rows ?: array() ) as $row ) { $items = json_decode( $row['cart_items'], true ); $item_count = is_array( $items ) ? count( $items ) : 0; $sessions[] = array( 'session_id' => $row['session_id'], 'agent' => $row['agent_identity'] ?? 'unknown', 'status' => $row['status'], 'items_count' => $item_count, 'total' => (float) $row['total_amount'], 'currency' => $row['currency'], 'wc_order_id' => $row['wc_order_id'] ? (int) $row['wc_order_id'] : null, 'created_at' => $row['created_at'], 'expires_at' => $row['expires_at'], ); } return new \WP_REST_Response( $sessions, 200 ); } /** * Get the MCP discovery data for /.well-known/mcp.json. * * @return array */ public function get_discovery_data(): array { $tools = $this->tools ? $this->tools->get_tool_definitions() : array(); $tool_names = array_column( $tools, 'name' ); return array( 'mcp_endpoint' => rest_url( 'aicommerce/v1/mcp' ), 'auth_required' => 'public' !== get_option( 'aicommerce_mcp_auth_mode', 'api_key' ), 'tools' => $tool_names, 'store' => array( 'name' => get_bloginfo( 'name' ), 'url' => home_url(), ), ); } }