php-mcp/src/Mcp/Tools/QueryDatabase.php
Snider 6f309979de refactor: move MCP module from Core\Mod\Mcp to Core\Mcp namespace
Relocates the MCP module to a top-level namespace as part of the
monorepo separation, removing the intermediate Mod directory layer.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 16:26:14 +00:00

281 lines
8.8 KiB
PHP

<?php
declare(strict_types=1);
namespace Core\Mcp\Tools;
use Core\Mcp\Exceptions\ForbiddenQueryException;
use Core\Mcp\Services\SqlQueryValidator;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\DB;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Tool;
/**
* MCP Tool for executing read-only SQL queries.
*
* Security measures:
* 1. Uses configurable read-only database connection
* 2. Validates queries against blocked keywords and patterns
* 3. Optional whitelist-based query validation
* 4. Blocks access to sensitive tables
* 5. Enforces row limits
*/
class QueryDatabase extends Tool
{
protected string $description = 'Execute a read-only SQL SELECT query against the database';
private SqlQueryValidator $validator;
public function __construct()
{
$this->validator = $this->createValidator();
}
public function handle(Request $request): Response
{
$query = $request->input('query');
$explain = $request->input('explain', false);
if (empty($query)) {
return $this->errorResponse('Query is required');
}
// Validate the query
try {
$this->validator->validate($query);
} catch (ForbiddenQueryException $e) {
return $this->errorResponse($e->getMessage());
}
// Check for blocked tables
$blockedTable = $this->checkBlockedTables($query);
if ($blockedTable !== null) {
return $this->errorResponse(
sprintf("Access to table '%s' is not permitted", $blockedTable)
);
}
// Apply row limit if not present
$query = $this->applyRowLimit($query);
try {
$connection = $this->getConnection();
// If explain is requested, run EXPLAIN first
if ($explain) {
return $this->handleExplain($connection, $query);
}
$results = DB::connection($connection)->select($query);
return Response::text(json_encode($results, JSON_PRETTY_PRINT));
} catch (\Exception $e) {
// Log the actual error for debugging but return sanitised message
report($e);
return $this->errorResponse('Query execution failed: '.$this->sanitiseErrorMessage($e->getMessage()));
}
}
public function schema(JsonSchema $schema): array
{
return [
'query' => $schema->string('SQL SELECT query to execute. Only read-only SELECT queries are permitted.'),
'explain' => $schema->boolean('If true, runs EXPLAIN on the query instead of executing it. Useful for query optimization and debugging.')->default(false),
];
}
/**
* Create the SQL validator with configuration.
*/
private function createValidator(): SqlQueryValidator
{
$useWhitelist = Config::get('mcp.database.use_whitelist', true);
$customPatterns = Config::get('mcp.database.whitelist_patterns', []);
$validator = new SqlQueryValidator(null, $useWhitelist);
foreach ($customPatterns as $pattern) {
$validator->addWhitelistPattern($pattern);
}
return $validator;
}
/**
* Get the database connection to use.
*
* @throws \RuntimeException If the configured connection is invalid
*/
private function getConnection(): ?string
{
$connection = Config::get('mcp.database.connection');
// If configured connection doesn't exist, throw exception
if ($connection && ! Config::has("database.connections.{$connection}")) {
throw new \RuntimeException(
"Invalid MCP database connection '{$connection}' configured. ".
"Please ensure 'database.connections.{$connection}' exists in your database configuration."
);
}
return $connection;
}
/**
* Check if the query references any blocked tables.
*/
private function checkBlockedTables(string $query): ?string
{
$blockedTables = Config::get('mcp.database.blocked_tables', []);
foreach ($blockedTables as $table) {
// Check for table references in various formats
$patterns = [
'/\bFROM\s+`?'.preg_quote($table, '/').'`?\b/i',
'/\bJOIN\s+`?'.preg_quote($table, '/').'`?\b/i',
'/\b'.preg_quote($table, '/').'\./i', // table.column format
];
foreach ($patterns as $pattern) {
if (preg_match($pattern, $query)) {
return $table;
}
}
}
return null;
}
/**
* Apply row limit to query if not already present.
*/
private function applyRowLimit(string $query): string
{
$maxRows = Config::get('mcp.database.max_rows', 1000);
// Check if LIMIT is already present
if (preg_match('/\bLIMIT\s+\d+/i', $query)) {
return $query;
}
// Remove trailing semicolon if present
$query = rtrim(trim($query), ';');
return $query.' LIMIT '.$maxRows;
}
/**
* Sanitise database error messages to avoid leaking sensitive information.
*/
private function sanitiseErrorMessage(string $message): string
{
// Remove specific database paths, credentials, etc.
$message = preg_replace('/\/[^\s]+/', '[path]', $message);
$message = preg_replace('/at \d+\.\d+\.\d+\.\d+/', 'at [ip]', $message);
// Truncate long messages
if (strlen($message) > 200) {
$message = substr($message, 0, 200).'...';
}
return $message;
}
/**
* Handle EXPLAIN query execution.
*/
private function handleExplain(?string $connection, string $query): Response
{
try {
// Run EXPLAIN on the query
$explainResults = DB::connection($connection)->select("EXPLAIN {$query}");
// Also try to get extended information if MySQL/MariaDB
$warnings = [];
try {
$warnings = DB::connection($connection)->select('SHOW WARNINGS');
} catch (\Exception $e) {
// SHOW WARNINGS may not be available on all databases
}
$response = [
'explain' => $explainResults,
'query' => $query,
];
if (! empty($warnings)) {
$response['warnings'] = $warnings;
}
// Add helpful interpretation
$response['interpretation'] = $this->interpretExplain($explainResults);
return Response::text(json_encode($response, JSON_PRETTY_PRINT));
} catch (\Exception $e) {
report($e);
return $this->errorResponse('EXPLAIN failed: '.$this->sanitiseErrorMessage($e->getMessage()));
}
}
/**
* Provide human-readable interpretation of EXPLAIN results.
*/
private function interpretExplain(array $explainResults): array
{
$interpretation = [];
foreach ($explainResults as $row) {
$rowAnalysis = [];
// Convert stdClass to array for easier access
$rowArray = (array) $row;
// Check for full table scan
if (isset($rowArray['type']) && $rowArray['type'] === 'ALL') {
$rowAnalysis[] = 'WARNING: Full table scan detected. Consider adding an index.';
}
// Check for filesort
if (isset($rowArray['Extra']) && str_contains($rowArray['Extra'], 'Using filesort')) {
$rowAnalysis[] = 'INFO: Using filesort. Query may benefit from an index on ORDER BY columns.';
}
// Check for temporary table
if (isset($rowArray['Extra']) && str_contains($rowArray['Extra'], 'Using temporary')) {
$rowAnalysis[] = 'INFO: Using temporary table. Consider optimizing the query.';
}
// Check rows examined
if (isset($rowArray['rows']) && $rowArray['rows'] > 10000) {
$rowAnalysis[] = sprintf('WARNING: High row count (%d rows). Query may be slow.', $rowArray['rows']);
}
// Check if index is used
if (isset($rowArray['key']) && $rowArray['key'] !== null) {
$rowAnalysis[] = sprintf('GOOD: Using index: %s', $rowArray['key']);
}
if (! empty($rowAnalysis)) {
$interpretation[] = [
'table' => $rowArray['table'] ?? 'unknown',
'analysis' => $rowAnalysis,
];
}
}
return $interpretation;
}
/**
* Create an error response.
*/
private function errorResponse(string $message): Response
{
return Response::text(json_encode(['error' => $message]));
}
}