$request_id, 'remote_ip' => $remote_ip, 'tool_name' => $tool_name, 'module' => $tool_module, 'status' => $lock_status, 'http_status' => 403, 'duration_ms' => self::elapsed( $start ), 'error_code' => $lock_code, 'params_json' => self::safe_json( $arguments ), ] ); return self::mcp_error( $lock_code, ucwords( str_replace( '_', ' ', $lock_status ) ), $request_id, $rpc_id ); } // ── Step 3: Auth ────────────────────────────────────────────────── $plain_key = AICOM_Auth::extract_key_from_request(); if ( ! $plain_key ) { return self::early_error( 'auth_failed', 'API key missing', 401, $request_id, $remote_ip, $tool_name, $tool_module, $start, 0, '', $rpc_id ); } $key_record = AICOM_Auth::validate_key( $plain_key ); if ( ! $key_record ) { return self::early_error( 'auth_failed', 'Invalid or revoked API key', 403, $request_id, $remote_ip, $tool_name, $tool_module, $start, 0, '', $rpc_id ); } if ( ! AICOM_Auth::check_ip_allowlist( $key_record, $remote_ip ) ) { return self::early_error( 'auth_failed', 'IP not in allowlist', 403, $request_id, $remote_ip, $tool_name, $tool_module, $start, (int) $key_record['id'], $key_record['label'], $rpc_id ); } $key_id = (int) $key_record['id']; $key_label = $key_record['label']; // ── Step 3.5: Session enforcement ───────────────────────────────── // Write/destructive/admin_sensitive tools require an open named session. // Exempt: session.open, session.close, tools/list, and read/discovery tools. $session_exempt = in_array( $tool_name, [ 'session.open', 'session.close', 'tools/list' ], true ) || in_array( $tool_class, [ 'read', 'discovery', 'public' ], true ) || $tool_meta === null; // unknown tools → rejected at step 4 if ( $session_exempt ) { self::$current_session_id = 0; } else { $active_session = AICOM_Sessions::get_active( $key_id ); if ( ! $active_session ) { return self::keyed_error( $request_id, $remote_ip, $key_id, $key_label, $tool_name, $tool_module, 'NO_ACTIVE_SESSION', 'Open a session first: session.open(name: "Describe what you\'re about to do")', 'validation_failed', 400, $arguments, $start, $rpc_id ); } self::$current_session_id = (int) $active_session['id']; } // ── Step 4: Tool exists ─────────────────────────────────────────── if ( $tool_meta === null ) { return self::keyed_error( $request_id, $remote_ip, $key_id, $key_label, $tool_name, $tool_module, 'TOOL_NOT_FOUND', "Tool not found: $tool_name", 'error', 404, $arguments, $start, $rpc_id ); } // ── Step 5: Dependency check ────────────────────────────────────── $dependency = $tool_meta['dependency']; if ( $dependency !== null ) { if ( $dependency === 'woocommerce' ) { $dep_active = AICOM_Module_Detector::is_woocommerce_active(); } elseif ( $dependency === 'elementor' ) { $dep_active = AICOM_Module_Detector::is_elementor_active(); } elseif ( $dependency === 'polylang' ) { $dep_active = AICOM_Module_Detector::is_polylang_active(); } elseif ( $dependency === 'ecs' ) { $dep_active = AICOM_Module_Detector::is_ecs_active(); } elseif ( $dependency === 'ecs_pro' ) { $dep_active = AICOM_Module_Detector::is_ecs_pro_active(); } elseif ( $dependency === 'clautron' ) { $dep_active = AICOM_Module_Detector::is_clautron_active(); } elseif ( $dependency === 'yoast' ) { $dep_active = AICOM_Module_Detector::is_yoast_active(); } else { $dep_active = false; } if ( ! $dep_active ) { return self::keyed_error( $request_id, $remote_ip, $key_id, $key_label, $tool_name, $tool_module, 'DEPENDENCY_MISSING', "Required plugin not active: $dependency", 'dependency_missing', 422, $arguments, $start, $rpc_id ); } } // ── Step 6: Scope check ─────────────────────────────────────────── if ( ! AICOM_Auth::check_scopes( $key_record, $tool_meta['required_scopes'] ) ) { return self::keyed_error( $request_id, $remote_ip, $key_id, $key_label, $tool_name, $tool_module, 'DENIED_SCOPE', 'Insufficient scope for this tool', 'denied_scope', 403, $arguments, $start, $rpc_id ); } // ── Step 7: (Delegated) Allowlist checks are done inside handlers ─ // ── Step 8: Confirm flag ────────────────────────────────────────── if ( ! AICOM_Policy_Engine::check_confirm_flag( $tool_meta, $arguments ) ) { return self::keyed_error( $request_id, $remote_ip, $key_id, $key_label, $tool_name, $tool_module, 'CONFIRM_REQUIRED', 'This operation requires arguments.confirm = true', 'validation_failed', 400, $arguments, $start, $rpc_id ); } // ── Step 9: Dry-run mode ────────────────────────────────────────── $is_dry_run = ! empty( $arguments['dry_run'] ) || AICOM_Policy_Engine::is_dry_run_only( $key_record ); // ── Step 10: Execute ────────────────────────────────────────────── $handler = $tool_meta['handler']; if ( ! is_callable( $handler ) ) { return self::keyed_error( $request_id, $remote_ip, $key_id, $key_label, $tool_name, $tool_module, 'HANDLER_MISSING', 'Tool handler not callable', 'error', 500, $arguments, $start, $rpc_id ); } // Grant capabilities only for the duration of tool execution. Using a filter // instead of wp_set_current_user() avoids impersonating any WordPress user account. add_filter( 'user_has_cap', [ 'AICOM_Tool_Router', 'grant_api_request_caps' ], 999 ); $exec_exception = null; try { $result = call_user_func( $handler, $arguments, $key_record, $is_dry_run ); } catch ( Throwable $e ) { $exec_exception = $e; } finally { remove_filter( 'user_has_cap', [ 'AICOM_Tool_Router', 'grant_api_request_caps' ], 999 ); } if ( $exec_exception !== null ) { return self::keyed_error( $request_id, $remote_ip, $key_id, $key_label, $tool_name, $tool_module, 'EXECUTION_ERROR', $exec_exception->getMessage(), 'error', 500, $arguments, $start, $rpc_id ); } // ── Step 11: Touch key ──────────────────────────────────────────── AICOM_Auth::touch_key( $key_id, $remote_ip ); // ── Step 12: Audit log + return ─────────────────────────────────── $duration = self::elapsed( $start ); $is_error = isset( $result['error'] ); $meta = $result['_meta'] ?? []; AICOM_Audit_Logger::log( [ 'request_id' => $request_id, 'session_id' => self::$current_session_id ?: null, 'remote_ip' => $remote_ip, 'api_key_id' => $key_id, 'api_key_label' => $key_label, 'tool_name' => $tool_name, 'module' => $tool_module, 'tool_class' => $tool_class, 'status' => $is_error ? ( $result['error']['status_code'] ?? 'error' ) : 'success', 'http_status' => $is_error ? ( $result['http_status'] ?? 500 ) : 200, 'duration_ms' => $duration, 'target_type' => $meta['target_type'] ?? null, 'target_id' => isset( $meta['target_id'] ) ? (string) $meta['target_id'] : null, 'is_dry_run' => $is_dry_run ? 1 : 0, 'error_code' => $is_error ? ( $result['error']['code'] ?? 'ERROR' ) : null, 'error_message' => $is_error ? ( $result['error']['message'] ?? '' ) : null, 'params_json' => self::safe_json( $arguments ), 'result_summary_json' => ! $is_error ? self::safe_json( $meta['summary'] ?? [] ) : null, ] ); // Strip internal keys before returning unset( $result['_meta'], $result['http_status'] ); $result['request_id'] = $request_id; return self::jsonrpc_wrap( $result, $rpc_id ); } // ── Error Builders ──────────────────────────────────────────────────── /** * Error before auth (no key_id known). Logs with minimal data. */ private static function early_error( string $status_code, string $message, int $http_status, string $request_id, string $remote_ip, string $tool_name, string $tool_module, float $start, int $key_id = 0, string $key_label = '', $rpc_id = null ): array { AICOM_Audit_Logger::log( [ 'request_id' => $request_id, 'remote_ip' => $remote_ip, 'api_key_id' => $key_id ?: null, 'api_key_label' => $key_label ?: null, 'tool_name' => $tool_name ?: 'unknown', 'module' => $tool_module, 'status' => $status_code, 'http_status' => $http_status, 'duration_ms' => self::elapsed( $start ), 'error_code' => strtoupper( $status_code ), 'error_message' => $message, ] ); return self::mcp_error( strtoupper( $status_code ), $message, $request_id, $rpc_id ); } /** * Error after auth (key_id known). */ private static function keyed_error( string $request_id, string $remote_ip, int $key_id, string $key_label, string $tool_name, string $tool_module, string $error_code, string $message, string $status_code, int $http_status, array $arguments, float $start, $rpc_id = null ): array { AICOM_Audit_Logger::log( [ 'request_id' => $request_id, 'remote_ip' => $remote_ip, 'api_key_id' => $key_id, 'api_key_label' => $key_label, 'tool_name' => $tool_name, 'module' => $tool_module, 'status' => $status_code, 'http_status' => $http_status, 'duration_ms' => self::elapsed( $start ), 'error_code' => $error_code, 'error_message' => $message, 'params_json' => self::safe_json( $arguments ), ] ); return self::mcp_error( $error_code, $message, $request_id, $rpc_id ); } private static function mcp_error( string $code, string $message, string $request_id, $rpc_id = null ): array { $response = [ 'error' => [ 'code' => $code, 'message' => $message ], 'request_id' => $request_id, ]; return self::jsonrpc_wrap( $response, $rpc_id ); } /** * Wrap response in JSON-RPC 2.0 envelope when client sent a jsonrpc request (id present). * For error responses, error stays at top level per JSON-RPC spec. * For success responses, payload moves into result key. */ private static function jsonrpc_wrap( array $response, $rpc_id ): array { if ( $rpc_id === null ) { return $response; // legacy / shorthand format — no wrapping } $base = [ 'jsonrpc' => '2.0', 'id' => $rpc_id ]; if ( isset( $response['error'] ) ) { return $base + [ 'error' => $response['error'], 'request_id' => $response['request_id'] ?? null, ]; } return $base + [ 'result' => $response ]; } // ── Helpers ─────────────────────────────────────────────────────────── private static function elapsed( float $start ): int { return (int) round( ( microtime( true ) - $start ) * 1000 ); } private static function safe_json( array $data ): string { // Strip potentially sensitive fields before logging $sanitized = $data; foreach ( [ 'password', 'pass', 'secret', 'key', 'token', 'api_key' ] as $sensitive ) { if ( isset( $sanitized[ $sensitive ] ) ) { $sanitized[ $sensitive ] = '***'; } } return wp_json_encode( $sanitized ) ?: '{}'; } /** * Temporary user_has_cap filter: grant capabilities needed for API tool execution. * Applied only during step 10 (Execute) of the dispatch pipeline and always removed * immediately after via try/finally — whether the handler succeeds or throws. * * @param array $allcaps Existing capability map for the current user. * @return array Augmented capability map. */ public static function grant_api_request_caps( array $allcaps ): array { $allcaps['manage_options'] = true; $allcaps['edit_posts'] = true; $allcaps['edit_others_posts'] = true; $allcaps['edit_published_posts'] = true; $allcaps['delete_posts'] = true; $allcaps['delete_others_posts'] = true; $allcaps['delete_published_posts'] = true; $allcaps['publish_posts'] = true; $allcaps['upload_files'] = true; $allcaps['manage_categories'] = true; $allcaps['list_users'] = true; $allcaps['edit_users'] = true; $allcaps['promote_users'] = true; $allcaps['create_users'] = true; $allcaps['delete_users'] = true; $allcaps['manage_woocommerce'] = true; return $allcaps; } public static function remote_ip(): string { // Respect X-Forwarded-For only if trusted (simplified — production should validate proxy). $forwarded = isset( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) : ''; if ( $forwarded ) { return sanitize_text_field( trim( explode( ',', $forwarded )[0] ) ); } return isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '0.0.0.0'; } }