PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.13
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.13
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 2.6.4 2.6.3 All 139 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.13, at wp-mcp-server/tools/class-mcp-tool-db-query.php

540 lines 20.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 // wp_options keys (or substrings) that must never be exposed via the
234 // generic SELECT tool. Used both to reject queries that name them
235 // outright and to redact them from result rows.
236 private $sensitive_option_names = [
237 'metasync_jwt_secret',
238 'metasync_options',
239 'apikey',
240 'api_key',
241 'password',
242 'secret',
243 'token',
244 ];
245
246 // Tables that must never be queried via the generic SELECT tool.
247 // These contain credentials and session data that no MCP agent
248 // has a legitimate reason to read via raw SQL.
249 private $blocked_tables = ['users', 'usermeta'];
250
251 // SQL functions that can encode/obfuscate string literals to bypass
252 // keyword-based filtering on wp_options queries.
253 private $obfuscation_patterns = [
254 'CONCAT(', 'CONCAT_WS(', 'UNHEX(', 'CHAR(', 'HEX(',
255 'CONV(', 'LOAD_FILE(', '0X',
256 ];
257
258 /**
259 * Execute a read-only SQL query.
260 *
261 * Query timeout behaviour
262 * -----------------------
263 * Every query is guarded by a timeout (default 10 seconds) so that a slow
264 * or runaway SELECT cannot hang the PHP process. The effective limit is
265 * resolved in this order:
266 *
267 * 1. `METASYNC_MCP_DB_QUERY_TIMEOUT` constant, if defined.
268 * 2. The `mcp_db_query_timeout` WordPress filter.
269 * 3. The default of 10 seconds.
270 *
271 * The resolved value is clamped to at least 1 second.
272 *
273 * The timeout is enforced server-side where possible:
274 *
275 * - MySQL >= 5.7.8: the statement is prefixed with the
276 * `MAX_EXECUTION_TIME(<ms>)` optimizer hint (milliseconds). The hint
277 * is only valid for SELECT and is skipped for SHOW / DESCRIBE /
278 * EXPLAIN.
279 * - MariaDB >= 10.1.1: the statement is wrapped with
280 * `SET STATEMENT max_statement_time=<seconds> FOR ...` (seconds, not
281 * milliseconds — this is a MariaDB specific distinction).
282 * - Older MySQL / MariaDB: no DB-level hint is injected.
283 *
284 * On all versions, the call is additionally wrapped by a PHP-level
285 * `set_time_limit()` fallback scoped to the query, restored in a
286 * `finally` block, so the query cannot hang the PHP process beyond the
287 * configured timeout + a small safety margin even when no DB-level hint
288 * is available.
289 *
290 * When the DB reports a timeout (MySQL error 3024, MariaDB error 1969,
291 * or a `Query execution was interrupted` / `max_statement_time exceeded`
292 * message) the tool throws a descriptive `Exception` which the JSON-RPC
293 * handler converts into a structured error response.
294 *
295 * @param array $params {
296 * @type string $sql SQL to run (SELECT / SHOW / DESCRIBE / EXPLAIN).
297 * @type int $limit Max rows to return (1–500, default 100).
298 * }
299 * @return array Success payload as returned by {@see self::success()}.
300 * @throws Exception If the query is blocked, times out, or the DB errors.
301 */
302 public function execute($params) {
303 $this->validate_params($params);
304 $this->require_capability('manage_options');
305
306 global $wpdb;
307
308 $sql = trim($params['sql']);
309 $limit = isset($params['limit']) ? intval($params['limit']) : 100;
310 $limit = max(1, min(500, $limit));
311
312 // ── Security checks ──────────────────────────────────────────
313 // NOTE: all security checks operate on the ORIGINAL $sql (before any
314 // timeout rewriting). Only the timeout-wrapped SQL is passed to
315 // $wpdb->get_results() further down.
316
317 // 1. Strip leading SQL block/line comments to find the real first keyword
318 $stripped = preg_replace('/\A(\s*(\/\*.*?\*\/\s*|--[^\n]*\n?\s*))+/s', '', $sql);
319 $first_keyword = strtoupper(preg_replace('/[\s(;].*/s', '', $stripped));
320
321 $allowed_starts = ['SELECT', 'SHOW', 'DESCRIBE', 'DESC', 'EXPLAIN'];
322 if (!in_array($first_keyword, $allowed_starts, true)) {
323 throw new Exception(
324 "Query blocked: only SELECT, SHOW, DESCRIBE, and EXPLAIN are allowed. " .
325 "Got: '{$first_keyword}'"
326 );
327 }
328
329 // 2. Block write-operation keywords at statement start (covers stacked queries)
330 foreach ($this->blocked_first_keywords as $kw) {
331 if (preg_match('/;\s*' . preg_quote($kw, '/') . '\s/i', $sql)) {
332 throw new Exception("Query blocked: stacked write statement detected ({$kw})");
333 }
334 }
335
336 // 3. Block dangerous function / file-access patterns
337 $sql_upper = strtoupper($sql);
338 foreach ($this->blocked_anywhere as $pattern) {
339 if (strpos($sql_upper, $pattern) !== false) {
340 throw new Exception("Query blocked: forbidden pattern detected ({$pattern})");
341 }
342 }
343
344 // 4. Block access to sensitive tables entirely.
345 //
346 // wp_users and wp_usermeta contain credentials, password hashes,
347 // and session tokens. No MCP agent has a legitimate reason to
348 // query them via raw SQL.
349 $sql_upper = strtoupper($sql);
350 foreach ($this->blocked_tables as $table_suffix) {
351 $full_table = $wpdb->prefix . $table_suffix;
352 if (stripos($sql, $full_table) !== false) {
353 throw new Exception(
354 "Query blocked: access to {$full_table} is restricted"
355 );
356 }
357 }
358
359 // 5. Sensitive wp_options protection.
360 //
361 // The JWT secret and other credential-bearing settings live in
362 // wp_options. Two layers protect them:
363 //
364 // (a) SQL-level reject — if the query targets wp_options and
365 // mentions a sensitive key by name, or uses encoding
366 // functions that could obfuscate a key name, refuse.
367 // (b) Result-row redaction (further down) — drop rows whose
368 // option_name matches a sensitive pattern.
369 $options_table = isset($wpdb->options) && is_string($wpdb->options) && $wpdb->options !== ''
370 ? $wpdb->options
371 : ($wpdb->prefix . 'options');
372 $sql_lower = strtolower($sql);
373 $references_options = strpos($sql_lower, strtolower($options_table)) !== false;
374
375 if ($first_keyword === 'SELECT' && $references_options) {
376 foreach ($this->sensitive_option_names as $sensitive_name) {
377 if (stripos($sql, $sensitive_name) !== false) {
378 throw new Exception(
379 "Query blocked: query references a sensitive wp_options key ({$sensitive_name})"
380 );
381 }
382 }
383
384 foreach ($this->obfuscation_patterns as $pattern) {
385 if (strpos($sql_upper, $pattern) !== false) {
386 throw new Exception(
387 "Query blocked: encoding functions are not allowed in wp_options queries"
388 );
389 }
390 }
391 }
392
393 // ── Execute ──────────────────────────────────────────────────
394 $to = $this->apply_query_timeout($sql);
395
396 $prev_time_limit = (int) ini_get('max_execution_time');
397 // Give the DB-level hint a chance to fire first; the PHP fallback
398 // trails by a small margin so it only kicks in as a hard backstop.
399 @set_time_limit($to['timeout'] + 2);
400
401 try {
402 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- user-provided query, validated above
403 $results = $wpdb->get_results($to['sql'], ARRAY_A);
404 } finally {
405 @set_time_limit($prev_time_limit);
406 }
407
408 if ($wpdb->last_error) {
409 $err = $wpdb->last_error;
410 if (
411 preg_match('/\b3024\b/', $err) || // MySQL ER_QUERY_TIMEOUT
412 preg_match('/\b1969\b/', $err) || // MariaDB ER_STATEMENT_TIMEOUT
413 stripos($err, 'Query execution was interrupted') !== false ||
414 stripos($err, 'max_statement_time exceeded') !== false
415 ) {
416 throw new Exception(
417 "Query timeout: query exceeded the {$to['timeout']}s limit and was cancelled."
418 );
419 }
420 throw new Exception('Database error: ' . $err);
421 }
422
423 // Layer (b) of sensitive wp_options protection: drop result rows
424 // whose option_name matches any sensitive pattern, even when the
425 // query did not name the key literally (e.g. SELECT * or LIKE).
426 if (is_array($results) && $references_options) {
427 $filtered = [];
428 foreach ($results as $row) {
429 if (is_array($row) && isset($row['option_name']) && is_string($row['option_name'])) {
430 $skip = false;
431 foreach ($this->sensitive_option_names as $sensitive_name) {
432 if (stripos($row['option_name'], $sensitive_name) !== false) {
433 $skip = true;
434 break;
435 }
436 }
437 if ($skip) {
438 continue;
439 }
440 }
441 $filtered[] = $row;
442 }
443 $results = array_values($filtered);
444 }
445
446 $total = is_array($results) ? count($results) : 0;
447 $trimmed = is_array($results) ? array_slice($results, 0, $limit) : [];
448
449 return $this->success([
450 'rows_returned' => count($trimmed),
451 'rows_total' => $total,
452 'limit_applied' => $total > $limit,
453 'results' => $trimmed,
454 ]);
455 }
456
457 /**
458 * Resolve the effective query timeout and rewrite the SQL to enforce it.
459 *
460 * @param string $sql Original SQL statement.
461 * @return array{sql:string,timeout:int,mechanism:string}
462 * The SQL to pass to $wpdb->get_results(), the effective
463 * timeout in seconds, and which mechanism applied
464 * ('mysql', 'mariadb', or 'php_only').
465 */
466 private function apply_query_timeout($sql) {
467 global $wpdb;
468
469 $default = defined('METASYNC_MCP_DB_QUERY_TIMEOUT')
470 ? (int) METASYNC_MCP_DB_QUERY_TIMEOUT
471 : 10;
472 $timeout = max(1, (int) apply_filters('mcp_db_query_timeout', $default));
473
474 $server_info = '';
475 if (is_object($wpdb) && method_exists($wpdb, 'db_server_info')) {
476 $info = $wpdb->db_server_info();
477 if (is_string($info)) {
478 $server_info = $info;
479 }
480 }
481
482 $is_mariadb = $server_info !== '' && stripos($server_info, 'MariaDB') !== false;
483
484 if ($is_mariadb) {
485 $version = '';
486 if (preg_match('/([0-9]+\.[0-9]+\.[0-9]+)-MariaDB/i', $server_info, $m)) {
487 $version = $m[1];
488 } elseif (preg_match('/([0-9]+\.[0-9]+\.[0-9]+)/', $server_info, $m)) {
489 $version = $m[1];
490 }
491 if ($version !== '' && version_compare($version, '10.1.1', '>=')) {
492 // MariaDB uses seconds. SET STATEMENT wraps any statement
493 // type, so SELECT / SHOW / DESCRIBE / EXPLAIN are all covered.
494 return [
495 'sql' => "SET STATEMENT max_statement_time={$timeout} FOR {$sql}",
496 'timeout' => $timeout,
497 'mechanism' => 'mariadb',
498 ];
499 }
500 } else {
501 $version = '';
502 if (preg_match('/^([0-9]+\.[0-9]+\.[0-9]+)/', $server_info, $m)) {
503 $version = $m[1];
504 }
505 if ($version !== '' && version_compare($version, '5.7.8', '>=')) {
506 // Find the first real keyword (ignoring leading comments) to
507 // decide whether the MAX_EXECUTION_TIME hint applies. The
508 // hint is only valid for SELECT.
509 $stripped = preg_replace(
510 '/\A(\s*(\/\*.*?\*\/\s*|--[^\n]*\n?\s*))+/s',
511 '',
512 $sql
513 );
514 $first_kw = strtoupper(preg_replace('/[\s(;].*/s', '', $stripped));
515 if ($first_kw === 'SELECT') {
516 $ms = $timeout * 1000;
517 $leading = substr($sql, 0, strlen($sql) - strlen($stripped));
518 $timeout_sql = $leading
519 . substr($stripped, 0, 6)
520 . " /*+ MAX_EXECUTION_TIME({$ms}) */"
521 . substr($stripped, 6);
522 return [
523 'sql' => $timeout_sql,
524 'timeout' => $timeout,
525 'mechanism' => 'mysql',
526 ];
527 }
528 }
529 }
530
531 // Unsupported DB version / non-SELECT on MySQL: rely on the PHP-level
532 // set_time_limit() fallback applied by the caller.
533 return [
534 'sql' => $sql,
535 'timeout' => $timeout,
536 'mechanism' => 'php_only',
537 ];
538 }
539 }
540