PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.5
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.5
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
metasync / wp-mcp-server / tools / class-mcp-tool-db-query.php

class-mcp-tool-db-query.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.6.5, at wp-mcp-server/tools/class-mcp-tool-db-query.php

443 lines 16.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * MCP Tools for Read-Only Database Inspection
4 *
5 * Provides safe, read-only database access for AI-assisted analysis.
6 * Only SELECT, SHOW, DESCRIBE, and EXPLAIN statements are permitted.
7 * All tools require manage_options capability (admin).
8 *
9 * @package MetaSync
10 * @subpackage MCP_Server/Tools
11 */
12
13 if (!defined('ABSPATH')) {
14 exit;
15 }
16
17 require_once plugin_dir_path(dirname(__FILE__)) . 'class-mcp-tool-base.php';
18
19 /**
20 * DB Tables Tool
21 *
22 * Lists all database tables with row counts and size estimates.
23 */
24 class MCP_Tool_DB_Tables extends MCP_Tool_Base {
25
26 public function get_name() {
27 return 'wordpress_db_tables';
28 }
29
30 public function get_description() {
31 return 'List all database tables with row counts and size (MB). Use this to understand the database structure before running targeted queries.';
32 }
33
34 public function get_input_schema() {
35 return [
36 'type' => 'object',
37 'properties' => [
38 'prefix_only' => [
39 'type' => 'boolean',
40 'description' => 'If true (default), only show tables matching the WordPress table prefix',
41 ],
42 ],
43 ];
44 }
45
46 public function execute($params) {
47 $this->require_capability('manage_options');
48
49 global $wpdb;
50
51 $prefix_only = !isset($params['prefix_only']) || $params['prefix_only'] !== false;
52
53 $rows = $wpdb->get_results(
54 $wpdb->prepare(
55 'SELECT
56 table_name AS `table`,
57 table_rows AS `rows_approx`,
58 ROUND((data_length + index_length) / 1024 / 1024, 4) AS `size_mb`,
59 data_length AS `data_bytes`,
60 index_length AS `index_bytes`,
61 create_time AS `created`,
62 update_time AS `last_updated`
63 FROM information_schema.tables
64 WHERE table_schema = %s
65 ORDER BY (data_length + index_length) DESC',
66 DB_NAME
67 ),
68 ARRAY_A
69 );
70
71 if ($prefix_only) {
72 $prefix = $wpdb->prefix;
73 $rows = array_values(array_filter($rows, function($r) use ($prefix) {
74 return strpos($r['table'], $prefix) === 0;
75 }));
76 }
77
78 // Cast numeric columns
79 foreach ($rows as &$r) {
80 $r['rows_approx'] = (int) $r['rows_approx'];
81 $r['size_mb'] = (float) $r['size_mb'];
82 }
83 unset($r);
84
85 return $this->success([
86 'total' => count($rows),
87 'tables' => $rows,
88 ]);
89 }
90 }
91
92 /**
93 * DB Describe Tool
94 *
95 * Returns the column definitions for a specific table.
96 */
97 class MCP_Tool_DB_Describe extends MCP_Tool_Base {
98
99 public function get_name() {
100 return 'wordpress_db_describe';
101 }
102
103 public function get_description() {
104 return 'Describe a database table: column names, types, nullability, keys, and defaults. Use before writing a SELECT query to know the exact column names.';
105 }
106
107 public function get_input_schema() {
108 return [
109 'type' => 'object',
110 'properties' => [
111 'table' => [
112 'type' => 'string',
113 'description' => 'Table name (with or without prefix, e.g. "posts" or "wp_posts")',
114 ],
115 ],
116 'required' => ['table'],
117 ];
118 }
119
120 public function execute($params) {
121 $this->validate_params($params);
122 $this->require_capability('manage_options');
123
124 global $wpdb;
125
126 $table = $this->resolve_table_name(sanitize_text_field($params['table']));
127 $this->assert_table_exists($table);
128
129 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name validated above
130 $columns = $wpdb->get_results("DESCRIBE `{$table}`", ARRAY_A);
131
132 $indexes = $wpdb->get_results("SHOW INDEX FROM `{$table}`", ARRAY_A);
133 $index_map = [];
134 foreach ($indexes as $idx) {
135 $col = $idx['Column_name'];
136 $index_map[$col][] = [
137 'key_name' => $idx['Key_name'],
138 'non_unique' => (bool) $idx['Non_unique'],
139 'index_type' => $idx['Index_type'],
140 ];
141 }
142
143 $result = [];
144 foreach ($columns as $col) {
145 $result[] = [
146 'column' => $col['Field'],
147 'type' => $col['Type'],
148 'null' => $col['Null'] === 'YES',
149 'key' => $col['Key'],
150 'default' => $col['Default'],
151 'extra' => $col['Extra'],
152 'indexes' => $index_map[$col['Field']] ?? [],
153 ];
154 }
155
156 return $this->success([
157 'table' => $table,
158 'columns' => $result,
159 ]);
160 }
161
162 private function resolve_table_name($table) {
163 global $wpdb;
164 // If already prefixed, use as-is; otherwise prepend prefix
165 if (strpos($table, $wpdb->prefix) === 0) {
166 return $table;
167 }
168 return $wpdb->prefix . $table;
169 }
170
171 private function assert_table_exists($table) {
172 global $wpdb;
173 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
174 $exists = $wpdb->get_var($wpdb->prepare(
175 'SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = %s AND table_name = %s',
176 DB_NAME,
177 $table
178 ));
179 if (!$exists) {
180 throw new Exception("Table '{$table}' does not exist");
181 }
182 }
183 }
184
185 /**
186 * DB Select Tool
187 *
188 * Executes a read-only SQL query (SELECT / SHOW / DESCRIBE / EXPLAIN only).
189 * Blocks all write operations. Results capped at 500 rows.
190 */
191 class MCP_Tool_DB_Select extends MCP_Tool_Base {
192
193 public function get_name() {
194 return 'wordpress_db_select';
195 }
196
197 public function get_description() {
198 return 'Run a read-only SQL query against the WordPress database. Only SELECT, SHOW, DESCRIBE, and EXPLAIN are allowed — all write operations are blocked. Results are capped at 500 rows. Use wordpress_db_tables and wordpress_db_describe first to understand the schema.';
199 }
200
201 public function get_input_schema() {
202 return [
203 'type' => 'object',
204 'properties' => [
205 'sql' => [
206 'type' => 'string',
207 'description' => 'SQL query to execute. Must start with SELECT, SHOW, DESCRIBE, or EXPLAIN.',
208 ],
209 'limit' => [
210 'type' => 'integer',
211 'description' => 'Max rows to return (1–500, default 100)',
212 'minimum' => 1,
213 'maximum' => 500,
214 ],
215 ],
216 'required' => ['sql'],
217 ];
218 }
219
220 // Keywords that must never appear at statement start
221 private $blocked_first_keywords = [
222 'INSERT', 'UPDATE', 'DELETE', 'REPLACE', 'DROP', 'CREATE',
223 'ALTER', 'TRUNCATE', 'RENAME', 'GRANT', 'REVOKE', 'CALL',
224 'EXEC', 'EXECUTE', 'LOAD', 'IMPORT',
225 ];
226
227 // Keywords that should never appear anywhere in the query
228 private $blocked_anywhere = [
229 'INTO OUTFILE', 'INTO DUMPFILE', 'LOAD_FILE',
230 'SLEEP(', 'BENCHMARK(',
231 ];
232
233 /**
234 * Execute a read-only SQL query.
235 *
236 * Query timeout behaviour
237 * -----------------------
238 * Every query is guarded by a timeout (default 10 seconds) so that a slow
239 * or runaway SELECT cannot hang the PHP process. The effective limit is
240 * resolved in this order:
241 *
242 * 1. `METASYNC_MCP_DB_QUERY_TIMEOUT` constant, if defined.
243 * 2. The `mcp_db_query_timeout` WordPress filter.
244 * 3. The default of 10 seconds.
245 *
246 * The resolved value is clamped to at least 1 second.
247 *
248 * The timeout is enforced server-side where possible:
249 *
250 * - MySQL >= 5.7.8: the statement is prefixed with the
251 * `MAX_EXECUTION_TIME(<ms>)` optimizer hint (milliseconds). The hint
252 * is only valid for SELECT and is skipped for SHOW / DESCRIBE /
253 * EXPLAIN.
254 * - MariaDB >= 10.1.1: the statement is wrapped with
255 * `SET STATEMENT max_statement_time=<seconds> FOR ...` (seconds, not
256 * milliseconds — this is a MariaDB specific distinction).
257 * - Older MySQL / MariaDB: no DB-level hint is injected.
258 *
259 * On all versions, the call is additionally wrapped by a PHP-level
260 * `set_time_limit()` fallback scoped to the query, restored in a
261 * `finally` block, so the query cannot hang the PHP process beyond the
262 * configured timeout + a small safety margin even when no DB-level hint
263 * is available.
264 *
265 * When the DB reports a timeout (MySQL error 3024, MariaDB error 1969,
266 * or a `Query execution was interrupted` / `max_statement_time exceeded`
267 * message) the tool throws a descriptive `Exception` which the JSON-RPC
268 * handler converts into a structured error response.
269 *
270 * @param array $params {
271 * @type string $sql SQL to run (SELECT / SHOW / DESCRIBE / EXPLAIN).
272 * @type int $limit Max rows to return (1–500, default 100).
273 * }
274 * @return array Success payload as returned by {@see self::success()}.
275 * @throws Exception If the query is blocked, times out, or the DB errors.
276 */
277 public function execute($params) {
278 $this->validate_params($params);
279 $this->require_capability('manage_options');
280
281 global $wpdb;
282
283 $sql = trim($params['sql']);
284 $limit = isset($params['limit']) ? intval($params['limit']) : 100;
285 $limit = max(1, min(500, $limit));
286
287 // ── Security checks ──────────────────────────────────────────
288 // NOTE: all security checks operate on the ORIGINAL $sql (before any
289 // timeout rewriting). Only the timeout-wrapped SQL is passed to
290 // $wpdb->get_results() further down.
291
292 // 1. Strip leading SQL block/line comments to find the real first keyword
293 $stripped = preg_replace('/\A(\s*(\/\*.*?\*\/\s*|--[^\n]*\n?\s*))+/s', '', $sql);
294 $first_keyword = strtoupper(preg_replace('/[\s(;].*/s', '', $stripped));
295
296 $allowed_starts = ['SELECT', 'SHOW', 'DESCRIBE', 'DESC', 'EXPLAIN'];
297 if (!in_array($first_keyword, $allowed_starts, true)) {
298 throw new Exception(
299 "Query blocked: only SELECT, SHOW, DESCRIBE, and EXPLAIN are allowed. " .
300 "Got: '{$first_keyword}'"
301 );
302 }
303
304 // 2. Block write-operation keywords at statement start (covers stacked queries)
305 foreach ($this->blocked_first_keywords as $kw) {
306 if (preg_match('/;\s*' . preg_quote($kw, '/') . '\s/i', $sql)) {
307 throw new Exception("Query blocked: stacked write statement detected ({$kw})");
308 }
309 }
310
311 // 3. Block dangerous function / file-access patterns
312 $sql_upper = strtoupper($sql);
313 foreach ($this->blocked_anywhere as $pattern) {
314 if (strpos($sql_upper, $pattern) !== false) {
315 throw new Exception("Query blocked: forbidden pattern detected ({$pattern})");
316 }
317 }
318
319 // ── Execute ──────────────────────────────────────────────────
320 $to = $this->apply_query_timeout($sql);
321
322 $prev_time_limit = (int) ini_get('max_execution_time');
323 // Give the DB-level hint a chance to fire first; the PHP fallback
324 // trails by a small margin so it only kicks in as a hard backstop.
325 @set_time_limit($to['timeout'] + 2);
326
327 try {
328 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- user-provided query, validated above
329 $results = $wpdb->get_results($to['sql'], ARRAY_A);
330 } finally {
331 @set_time_limit($prev_time_limit);
332 }
333
334 if ($wpdb->last_error) {
335 $err = $wpdb->last_error;
336 if (
337 preg_match('/\b3024\b/', $err) || // MySQL ER_QUERY_TIMEOUT
338 preg_match('/\b1969\b/', $err) || // MariaDB ER_STATEMENT_TIMEOUT
339 stripos($err, 'Query execution was interrupted') !== false ||
340 stripos($err, 'max_statement_time exceeded') !== false
341 ) {
342 throw new Exception(
343 "Query timeout: query exceeded the {$to['timeout']}s limit and was cancelled."
344 );
345 }
346 throw new Exception('Database error: ' . $err);
347 }
348
349 $total = is_array($results) ? count($results) : 0;
350 $trimmed = is_array($results) ? array_slice($results, 0, $limit) : [];
351
352 return $this->success([
353 'rows_returned' => count($trimmed),
354 'rows_total' => $total,
355 'limit_applied' => $total > $limit,
356 'results' => $trimmed,
357 ]);
358 }
359
360 /**
361 * Resolve the effective query timeout and rewrite the SQL to enforce it.
362 *
363 * @param string $sql Original SQL statement.
364 * @return array{sql:string,timeout:int,mechanism:string}
365 * The SQL to pass to $wpdb->get_results(), the effective
366 * timeout in seconds, and which mechanism applied
367 * ('mysql', 'mariadb', or 'php_only').
368 */
369 private function apply_query_timeout($sql) {
370 global $wpdb;
371
372 $default = defined('METASYNC_MCP_DB_QUERY_TIMEOUT')
373 ? (int) METASYNC_MCP_DB_QUERY_TIMEOUT
374 : 10;
375 $timeout = max(1, (int) apply_filters('mcp_db_query_timeout', $default));
376
377 $server_info = '';
378 if (is_object($wpdb) && method_exists($wpdb, 'db_server_info')) {
379 $info = $wpdb->db_server_info();
380 if (is_string($info)) {
381 $server_info = $info;
382 }
383 }
384
385 $is_mariadb = $server_info !== '' && stripos($server_info, 'MariaDB') !== false;
386
387 if ($is_mariadb) {
388 $version = '';
389 if (preg_match('/([0-9]+\.[0-9]+\.[0-9]+)-MariaDB/i', $server_info, $m)) {
390 $version = $m[1];
391 } elseif (preg_match('/([0-9]+\.[0-9]+\.[0-9]+)/', $server_info, $m)) {
392 $version = $m[1];
393 }
394 if ($version !== '' && version_compare($version, '10.1.1', '>=')) {
395 // MariaDB uses seconds. SET STATEMENT wraps any statement
396 // type, so SELECT / SHOW / DESCRIBE / EXPLAIN are all covered.
397 return [
398 'sql' => "SET STATEMENT max_statement_time={$timeout} FOR {$sql}",
399 'timeout' => $timeout,
400 'mechanism' => 'mariadb',
401 ];
402 }
403 } else {
404 $version = '';
405 if (preg_match('/^([0-9]+\.[0-9]+\.[0-9]+)/', $server_info, $m)) {
406 $version = $m[1];
407 }
408 if ($version !== '' && version_compare($version, '5.7.8', '>=')) {
409 // Find the first real keyword (ignoring leading comments) to
410 // decide whether the MAX_EXECUTION_TIME hint applies. The
411 // hint is only valid for SELECT.
412 $stripped = preg_replace(
413 '/\A(\s*(\/\*.*?\*\/\s*|--[^\n]*\n?\s*))+/s',
414 '',
415 $sql
416 );
417 $first_kw = strtoupper(preg_replace('/[\s(;].*/s', '', $stripped));
418 if ($first_kw === 'SELECT') {
419 $ms = $timeout * 1000;
420 $leading = substr($sql, 0, strlen($sql) - strlen($stripped));
421 $timeout_sql = $leading
422 . substr($stripped, 0, 6)
423 . " /*+ MAX_EXECUTION_TIME({$ms}) */"
424 . substr($stripped, 6);
425 return [
426 'sql' => $timeout_sql,
427 'timeout' => $timeout,
428 'mechanism' => 'mysql',
429 ];
430 }
431 }
432 }
433
434 // Unsupported DB version / non-SELECT on MySQL: rely on the PHP-level
435 // set_time_limit() fallback applied by the caller.
436 return [
437 'sql' => $sql,
438 'timeout' => $timeout,
439 'mechanism' => 'php_only',
440 ];
441 }
442 }
443