PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.2
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.2
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.7.2, at wp-mcp-server/tools/class-mcp-tool-db-query.php

538 lines 22.8 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( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- the tool purpose is executing caller-supplied, allowlisted SQL — direct $wpdb is the feature
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 $columns = $wpdb->get_results("DESCRIBE `{$table}`", ARRAY_A); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery -- the tool purpose is executing caller-supplied, allowlisted SQL — direct $wpdb is the feature -- table name validated above
130
131 $indexes = $wpdb->get_results("SHOW INDEX FROM `{$table}`", ARRAY_A); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery -- the tool purpose is executing caller-supplied, allowlisted SQL — direct $wpdb is the feature -- table name validated above
132 $index_map = [];
133 foreach ($indexes as $idx) {
134 $col = $idx['Column_name'];
135 $index_map[$col][] = [
136 'key_name' => $idx['Key_name'],
137 'non_unique' => (bool) $idx['Non_unique'],
138 'index_type' => $idx['Index_type'],
139 ];
140 }
141
142 $result = [];
143 foreach ($columns as $col) {
144 $result[] = [
145 'column' => $col['Field'],
146 'type' => $col['Type'],
147 'null' => $col['Null'] === 'YES',
148 'key' => $col['Key'],
149 'default' => $col['Default'],
150 'extra' => $col['Extra'],
151 'indexes' => $index_map[$col['Field']] ?? [],
152 ];
153 }
154
155 return $this->success([
156 'table' => $table,
157 'columns' => $result,
158 ]);
159 }
160
161 private function resolve_table_name($table) {
162 global $wpdb;
163 // If already prefixed, use as-is; otherwise prepend prefix
164 if (strpos($table, $wpdb->prefix) === 0) {
165 return $table;
166 }
167 return $wpdb->prefix . $table;
168 }
169
170 private function assert_table_exists($table) {
171 global $wpdb;
172 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
173 $exists = $wpdb->get_var($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- the tool purpose is executing caller-supplied, allowlisted SQL — direct $wpdb is the feature
174 'SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = %s AND table_name = %s',
175 DB_NAME,
176 $table
177 ));
178 if (!$exists) {
179 throw new Exception("Table '{$table}' does not exist"); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- JSON-RPC error payload, JSON-encoded on output, never rendered as HTML; escaping here would corrupt message bytes
180 }
181 }
182 }
183
184 /**
185 * DB Select Tool
186 *
187 * Executes a read-only SQL query (SELECT / SHOW / DESCRIBE / EXPLAIN only).
188 * Blocks all write operations. Results capped at 500 rows.
189 */
190 class MCP_Tool_DB_Select extends MCP_Tool_Base {
191
192 public function get_name() {
193 return 'wordpress_db_select';
194 }
195
196 public function get_description() {
197 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.';
198 }
199
200 public function get_input_schema() {
201 return [
202 'type' => 'object',
203 'properties' => [
204 'sql' => [
205 'type' => 'string',
206 'description' => 'SQL query to execute. Must start with SELECT, SHOW, DESCRIBE, or EXPLAIN.',
207 ],
208 'limit' => [
209 'type' => 'integer',
210 'description' => 'Max rows to return (1–500, default 100)',
211 'minimum' => 1,
212 'maximum' => 500,
213 ],
214 ],
215 'required' => ['sql'],
216 ];
217 }
218
219 // Keywords that must never appear at statement start
220 private $blocked_first_keywords = [
221 'INSERT', 'UPDATE', 'DELETE', 'REPLACE', 'DROP', 'CREATE',
222 'ALTER', 'TRUNCATE', 'RENAME', 'GRANT', 'REVOKE', 'CALL',
223 'EXEC', 'EXECUTE', 'LOAD', 'IMPORT',
224 ];
225
226 // Keywords that should never appear anywhere in the query
227 private $blocked_anywhere = [
228 'INTO OUTFILE', 'INTO DUMPFILE', 'LOAD_FILE',
229 'SLEEP(', 'BENCHMARK(',
230 ];
231
232 // wp_options keys (or substrings) that must never be exposed via the
233 // generic SELECT tool. Used both to reject queries that name them
234 // outright and to redact them from result rows.
235 private $sensitive_option_names = [
236 'metasync_jwt_secret',
237 'metasync_options',
238 'apikey',
239 'api_key',
240 'password',
241 'secret',
242 'token',
243 ];
244
245 // Tables that must never be queried via the generic SELECT tool.
246 // These contain credentials and session data that no MCP agent
247 // has a legitimate reason to read via raw SQL.
248 private $blocked_tables = ['users', 'usermeta'];
249
250 // SQL functions that can encode/obfuscate string literals to bypass
251 // keyword-based filtering on wp_options queries.
252 private $obfuscation_patterns = [
253 'CONCAT(', 'CONCAT_WS(', 'UNHEX(', 'CHAR(', 'HEX(',
254 'CONV(', 'LOAD_FILE(', '0X',
255 ];
256
257 /**
258 * Execute a read-only SQL query.
259 *
260 * Query timeout behaviour
261 * -----------------------
262 * Every query is guarded by a timeout (default 10 seconds) so that a slow
263 * or runaway SELECT cannot hang the PHP process. The effective limit is
264 * resolved in this order:
265 *
266 * 1. `METASYNC_MCP_DB_QUERY_TIMEOUT` constant, if defined.
267 * 2. The `mcp_db_query_timeout` WordPress filter.
268 * 3. The default of 10 seconds.
269 *
270 * The resolved value is clamped to at least 1 second.
271 *
272 * The timeout is enforced server-side where possible:
273 *
274 * - MySQL >= 5.7.8: the statement is prefixed with the
275 * `MAX_EXECUTION_TIME(<ms>)` optimizer hint (milliseconds). The hint
276 * is only valid for SELECT and is skipped for SHOW / DESCRIBE /
277 * EXPLAIN.
278 * - MariaDB >= 10.1.1: the statement is wrapped with
279 * `SET STATEMENT max_statement_time=<seconds> FOR ...` (seconds, not
280 * milliseconds — this is a MariaDB specific distinction).
281 * - Older MySQL / MariaDB: no DB-level hint is injected.
282 *
283 * On all versions, the call is additionally wrapped by a PHP-level
284 * `set_time_limit()` fallback scoped to the query, restored in a
285 * `finally` block, so the query cannot hang the PHP process beyond the
286 * configured timeout + a small safety margin even when no DB-level hint
287 * is available.
288 *
289 * When the DB reports a timeout (MySQL error 3024, MariaDB error 1969,
290 * or a `Query execution was interrupted` / `max_statement_time exceeded`
291 * message) the tool throws a descriptive `Exception` which the JSON-RPC
292 * handler converts into a structured error response.
293 *
294 * @param array $params {
295 * @type string $sql SQL to run (SELECT / SHOW / DESCRIBE / EXPLAIN).
296 * @type int $limit Max rows to return (1–500, default 100).
297 * }
298 * @return array Success payload as returned by {@see self::success()}.
299 * @throws Exception If the query is blocked, times out, or the DB errors.
300 */
301 public function execute($params) {
302 $this->validate_params($params);
303 $this->require_capability('manage_options');
304
305 global $wpdb;
306
307 $sql = trim($params['sql']);
308 $limit = isset($params['limit']) ? intval($params['limit']) : 100;
309 $limit = max(1, min(500, $limit));
310
311 // ── Security checks ──────────────────────────────────────────
312 // NOTE: all security checks operate on the ORIGINAL $sql (before any
313 // timeout rewriting). Only the timeout-wrapped SQL is passed to
314 // $wpdb->get_results() further down.
315
316 // 1. Strip leading SQL block/line comments to find the real first keyword
317 $stripped = preg_replace('/\A(\s*(\/\*.*?\*\/\s*|--[^\n]*\n?\s*))+/s', '', $sql);
318 $first_keyword = strtoupper(preg_replace('/[\s(;].*/s', '', $stripped));
319
320 $allowed_starts = ['SELECT', 'SHOW', 'DESCRIBE', 'DESC', 'EXPLAIN'];
321 if (!in_array($first_keyword, $allowed_starts, true)) {
322 throw new Exception(
323 "Query blocked: only SELECT, SHOW, DESCRIBE, and EXPLAIN are allowed. " .
324 "Got: '{$first_keyword}'" // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- JSON-RPC error payload, JSON-encoded on output, never rendered as HTML; escaping here would corrupt message bytes
325 );
326 }
327
328 // 2. Block write-operation keywords at statement start (covers stacked queries)
329 foreach ($this->blocked_first_keywords as $kw) {
330 if (preg_match('/;\s*' . preg_quote($kw, '/') . '\s/i', $sql)) {
331 throw new Exception("Query blocked: stacked write statement detected ({$kw})"); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- JSON-RPC error payload, JSON-encoded on output, never rendered as HTML; escaping here would corrupt message bytes
332 }
333 }
334
335 // 3. Block dangerous function / file-access patterns
336 $sql_upper = strtoupper($sql);
337 foreach ($this->blocked_anywhere as $pattern) {
338 if (strpos($sql_upper, $pattern) !== false) {
339 throw new Exception("Query blocked: forbidden pattern detected ({$pattern})"); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- JSON-RPC error payload, JSON-encoded on output, never rendered as HTML; escaping here would corrupt message bytes
340 }
341 }
342
343 // 4. Block access to sensitive tables entirely.
344 //
345 // wp_users and wp_usermeta contain credentials, password hashes,
346 // and session tokens. No MCP agent has a legitimate reason to
347 // query them via raw SQL.
348 $sql_upper = strtoupper($sql);
349 foreach ($this->blocked_tables as $table_suffix) {
350 $full_table = $wpdb->prefix . $table_suffix;
351 if (stripos($sql, $full_table) !== false) {
352 throw new Exception(
353 "Query blocked: access to {$full_table} is restricted" // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- JSON-RPC error payload, JSON-encoded on output, never rendered as HTML; escaping here would corrupt message bytes
354 );
355 }
356 }
357
358 // 5. Sensitive wp_options protection.
359 //
360 // The JWT secret and other credential-bearing settings live in
361 // wp_options. Two layers protect them:
362 //
363 // (a) SQL-level reject — if the query targets wp_options and
364 // mentions a sensitive key by name, or uses encoding
365 // functions that could obfuscate a key name, refuse.
366 // (b) Result-row redaction (further down) — drop rows whose
367 // option_name matches a sensitive pattern.
368 $options_table = isset($wpdb->options) && is_string($wpdb->options) && $wpdb->options !== ''
369 ? $wpdb->options
370 : ($wpdb->prefix . 'options');
371 $sql_lower = strtolower($sql);
372 $references_options = strpos($sql_lower, strtolower($options_table)) !== false;
373
374 if ($first_keyword === 'SELECT' && $references_options) {
375 foreach ($this->sensitive_option_names as $sensitive_name) {
376 if (stripos($sql, $sensitive_name) !== false) {
377 throw new Exception(
378 "Query blocked: query references a sensitive wp_options key ({$sensitive_name})" // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- JSON-RPC error payload, JSON-encoded on output, never rendered as HTML; escaping here would corrupt message bytes
379 );
380 }
381 }
382
383 foreach ($this->obfuscation_patterns as $pattern) {
384 if (strpos($sql_upper, $pattern) !== false) {
385 throw new Exception(
386 "Query blocked: encoding functions are not allowed in wp_options queries"
387 );
388 }
389 }
390 }
391
392 // ── Execute ──────────────────────────────────────────────────
393 $to = $this->apply_query_timeout($sql);
394
395 $prev_time_limit = (int) ini_get('max_execution_time');
396 // Give the DB-level hint a chance to fire first; the PHP fallback
397 // trails by a small margin so it only kicks in as a hard backstop.
398 @set_time_limit($to['timeout'] + 2); // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- runtime PHP setting with no WordPress API; per-query timeout backstop
399
400 try {
401 $results = $wpdb->get_results($to['sql'], ARRAY_A); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery -- the tool purpose is executing caller-supplied, allowlisted SQL — direct $wpdb is the feature -- user-provided query, validated above
402 } finally {
403 @set_time_limit($prev_time_limit); // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- runtime PHP setting with no WordPress API; per-query timeout backstop
404 }
405
406 if ($wpdb->last_error) {
407 $err = $wpdb->last_error;
408 if (
409 preg_match('/\b3024\b/', $err) || // MySQL ER_QUERY_TIMEOUT
410 preg_match('/\b1969\b/', $err) || // MariaDB ER_STATEMENT_TIMEOUT
411 stripos($err, 'Query execution was interrupted') !== false ||
412 stripos($err, 'max_statement_time exceeded') !== false
413 ) {
414 throw new Exception(
415 "Query timeout: query exceeded the {$to['timeout']}s limit and was cancelled." // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- JSON-RPC error payload, JSON-encoded on output, never rendered as HTML; escaping here would corrupt message bytes
416 );
417 }
418 throw new Exception('Database error: ' . $err); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- JSON-RPC error payload, JSON-encoded on output, never rendered as HTML; escaping here would corrupt message bytes
419 }
420
421 // Layer (b) of sensitive wp_options protection: drop result rows
422 // whose option_name matches any sensitive pattern, even when the
423 // query did not name the key literally (e.g. SELECT * or LIKE).
424 if (is_array($results) && $references_options) {
425 $filtered = [];
426 foreach ($results as $row) {
427 if (is_array($row) && isset($row['option_name']) && is_string($row['option_name'])) {
428 $skip = false;
429 foreach ($this->sensitive_option_names as $sensitive_name) {
430 if (stripos($row['option_name'], $sensitive_name) !== false) {
431 $skip = true;
432 break;
433 }
434 }
435 if ($skip) {
436 continue;
437 }
438 }
439 $filtered[] = $row;
440 }
441 $results = array_values($filtered);
442 }
443
444 $total = is_array($results) ? count($results) : 0;
445 $trimmed = is_array($results) ? array_slice($results, 0, $limit) : [];
446
447 return $this->success([
448 'rows_returned' => count($trimmed),
449 'rows_total' => $total,
450 'limit_applied' => $total > $limit,
451 'results' => $trimmed,
452 ]);
453 }
454
455 /**
456 * Resolve the effective query timeout and rewrite the SQL to enforce it.
457 *
458 * @param string $sql Original SQL statement.
459 * @return array{sql:string,timeout:int,mechanism:string}
460 * The SQL to pass to $wpdb->get_results(), the effective
461 * timeout in seconds, and which mechanism applied
462 * ('mysql', 'mariadb', or 'php_only').
463 */
464 private function apply_query_timeout($sql) {
465 global $wpdb;
466
467 $default = defined('METASYNC_MCP_DB_QUERY_TIMEOUT')
468 ? (int) METASYNC_MCP_DB_QUERY_TIMEOUT
469 : 10;
470 $timeout = max(1, (int) apply_filters('mcp_db_query_timeout', $default));
471
472 $server_info = '';
473 if (is_object($wpdb) && method_exists($wpdb, 'db_server_info')) {
474 $info = $wpdb->db_server_info();
475 if (is_string($info)) {
476 $server_info = $info;
477 }
478 }
479
480 $is_mariadb = $server_info !== '' && stripos($server_info, 'MariaDB') !== false;
481
482 if ($is_mariadb) {
483 $version = '';
484 if (preg_match('/([0-9]+\.[0-9]+\.[0-9]+)-MariaDB/i', $server_info, $m)) {
485 $version = $m[1];
486 } elseif (preg_match('/([0-9]+\.[0-9]+\.[0-9]+)/', $server_info, $m)) {
487 $version = $m[1];
488 }
489 if ($version !== '' && version_compare($version, '10.1.1', '>=')) {
490 // MariaDB uses seconds. SET STATEMENT wraps any statement
491 // type, so SELECT / SHOW / DESCRIBE / EXPLAIN are all covered.
492 return [
493 'sql' => "SET STATEMENT max_statement_time={$timeout} FOR {$sql}",
494 'timeout' => $timeout,
495 'mechanism' => 'mariadb',
496 ];
497 }
498 } else {
499 $version = '';
500 if (preg_match('/^([0-9]+\.[0-9]+\.[0-9]+)/', $server_info, $m)) {
501 $version = $m[1];
502 }
503 if ($version !== '' && version_compare($version, '5.7.8', '>=')) {
504 // Find the first real keyword (ignoring leading comments) to
505 // decide whether the MAX_EXECUTION_TIME hint applies. The
506 // hint is only valid for SELECT.
507 $stripped = preg_replace(
508 '/\A(\s*(\/\*.*?\*\/\s*|--[^\n]*\n?\s*))+/s',
509 '',
510 $sql
511 );
512 $first_kw = strtoupper(preg_replace('/[\s(;].*/s', '', $stripped));
513 if ($first_kw === 'SELECT') {
514 $ms = $timeout * 1000;
515 $leading = substr($sql, 0, strlen($sql) - strlen($stripped));
516 $timeout_sql = $leading
517 . substr($stripped, 0, 6)
518 . " /*+ MAX_EXECUTION_TIME({$ms}) */"
519 . substr($stripped, 6);
520 return [
521 'sql' => $timeout_sql,
522 'timeout' => $timeout,
523 'mechanism' => 'mysql',
524 ];
525 }
526 }
527 }
528
529 // Unsupported DB version / non-SELECT on MySQL: rely on the PHP-level
530 // set_time_limit() fallback applied by the caller.
531 return [
532 'sql' => $sql,
533 'timeout' => $timeout,
534 'mechanism' => 'php_only',
535 ];
536 }
537 }
538