diff --git a/src/php/docs/tools-reference.md b/src/php/docs/tools-reference.md index 1dd1d52..0ccbfc5 100644 --- a/src/php/docs/tools-reference.md +++ b/src/php/docs/tools-reference.md @@ -140,6 +140,75 @@ List all database tables in the application. --- +### describe_table + +Describe a database table, including its columns and indexes. + +**Description:** Describe a database table, including columns and indexes + +**Parameters:** + +| Name | Type | Required | Description | +|------|------|----------|-------------| +| `table` | string | Yes | Database table name to inspect | + +**Example Request:** + +```json +{ + "tool": "describe_table", + "arguments": { + "table": "users" + } +} +``` + +**Success Response:** + +```json +{ + "table": "users", + "columns": [ + { + "field": "id", + "type": "bigint unsigned", + "collation": null, + "null": "NO", + "key": "PRI", + "default": null, + "extra": "auto_increment", + "privileges": "select,insert,update,references", + "comment": "Primary key" + } + ], + "indexes": [ + { + "name": "PRIMARY", + "unique": true, + "type": "BTREE", + "columns": [ + { + "name": "id", + "order": 1, + "collation": "A", + "cardinality": 1, + "sub_part": null, + "nullable": "", + "comment": "" + } + ] + } + ] +} +``` + +**Security Notes:** +- Table names are validated to allow only letters, numbers, and underscores +- System tables are blocked +- Table access may be filtered based on configuration + +--- + ## Commerce Tools ### get_billing_status @@ -690,6 +759,7 @@ curl -X POST https://api.example.com/mcp/tools/call \ ### Query Tools - `query_database` - Execute SQL queries - `list_tables` - List database tables +- `describe_table` - Inspect table columns and indexes ### Commerce Tools - `get_billing_status` - Get subscription status diff --git a/src/php/src/Mcp/Services/ToolRegistry.php b/src/php/src/Mcp/Services/ToolRegistry.php index 66de2fb..44766e0 100644 --- a/src/php/src/Mcp/Services/ToolRegistry.php +++ b/src/php/src/Mcp/Services/ToolRegistry.php @@ -33,6 +33,9 @@ class ToolRegistry 'query' => 'SELECT id, name FROM users LIMIT 10', ], 'list_tables' => [], + 'describe_table' => [ + 'table' => 'users', + ], 'list_routes' => [], 'list_sites' => [], 'get_stats' => [], diff --git a/src/php/src/Mcp/Tools/DescribeTable.php b/src/php/src/Mcp/Tools/DescribeTable.php new file mode 100644 index 0000000..e168b29 --- /dev/null +++ b/src/php/src/Mcp/Tools/DescribeTable.php @@ -0,0 +1,151 @@ +input('table', '')); + + if ($table === '') { + return $this->errorResponse('Table name is required'); + } + + if (! $this->isValidTableName($table)) { + return $this->errorResponse('Invalid table name. Use only letters, numbers, and underscores.'); + } + + if ($this->isBlockedTable($table)) { + return $this->errorResponse(sprintf("Access to table '%s' is not permitted", $table)); + } + + try { + $columns = DB::select("SHOW FULL COLUMNS FROM `{$table}`"); + $indexes = DB::select("SHOW INDEX FROM `{$table}`"); + } catch (\Throwable $e) { + report($e); + + return $this->errorResponse(sprintf('Unable to describe table "%s"', $table)); + } + + $result = [ + 'table' => $table, + 'columns' => array_map( + fn (object $column): array => $this->normaliseColumn((array) $column), + $columns + ), + 'indexes' => $this->normaliseIndexes($indexes), + ]; + + return Response::text(json_encode($result, JSON_PRETTY_PRINT)); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'table' => $schema->string('Database table name to inspect'), + ]; + } + + /** + * Validate the table name before interpolating it into SQL. + */ + private function isValidTableName(string $table): bool + { + return (bool) preg_match('/^[A-Za-z0-9_]+$/', $table); + } + + /** + * Check whether the table is blocked by configuration or is a system table. + */ + private function isBlockedTable(string $table): bool + { + $blockedTables = Config::get('mcp.database.blocked_tables', []); + + if (in_array($table, $blockedTables, true)) { + return true; + } + + $systemTables = ['information_schema', 'mysql', 'performance_schema', 'sys']; + + return in_array(strtolower($table), $systemTables, true); + } + + /** + * Normalise a SHOW FULL COLUMNS row into a predictable array shape. + * + * @param array $column + * @return array + */ + private function normaliseColumn(array $column): array + { + return [ + 'field' => $column['Field'] ?? null, + 'type' => $column['Type'] ?? null, + 'collation' => $column['Collation'] ?? null, + 'null' => $column['Null'] ?? null, + 'key' => $column['Key'] ?? null, + 'default' => $column['Default'] ?? null, + 'extra' => $column['Extra'] ?? null, + 'privileges' => $column['Privileges'] ?? null, + 'comment' => $column['Comment'] ?? null, + ]; + } + + /** + * Group SHOW INDEX rows by index name. + * + * @param array $indexes + * @return array> + */ + private function normaliseIndexes(array $indexes): array + { + $grouped = []; + + foreach ($indexes as $index) { + $row = (array) $index; + $name = (string) ($row['Key_name'] ?? 'unknown'); + + if (! isset($grouped[$name])) { + $grouped[$name] = [ + 'name' => $name, + 'unique' => ! (bool) ($row['Non_unique'] ?? 1), + 'type' => $row['Index_type'] ?? null, + 'columns' => [], + ]; + } + + $grouped[$name]['columns'][] = [ + 'name' => $row['Column_name'] ?? null, + 'order' => $row['Seq_in_index'] ?? null, + 'collation' => $row['Collation'] ?? null, + 'cardinality' => $row['Cardinality'] ?? null, + 'sub_part' => $row['Sub_part'] ?? null, + 'nullable' => $row['Null'] ?? null, + 'comment' => $row['Comment'] ?? null, + ]; + } + + return array_values($grouped); + } + + private function errorResponse(string $message): Response + { + return Response::text(json_encode([ + 'error' => $message, + 'code' => 'VALIDATION_ERROR', + ])); + } +} diff --git a/src/php/tests/Unit/DescribeTableTest.php b/src/php/tests/Unit/DescribeTableTest.php new file mode 100644 index 0000000..f90a008 --- /dev/null +++ b/src/php/tests/Unit/DescribeTableTest.php @@ -0,0 +1,113 @@ +once() + ->with('SHOW FULL COLUMNS FROM `users`') + ->andReturn([ + (object) [ + 'Field' => 'id', + 'Type' => 'bigint unsigned', + 'Null' => 'NO', + 'Key' => 'PRI', + 'Default' => null, + 'Extra' => 'auto_increment', + 'Privileges' => 'select,insert,update,references', + 'Comment' => 'Primary key', + ], + (object) [ + 'Field' => 'email', + 'Type' => 'varchar(255)', + 'Null' => 'NO', + 'Key' => 'UNI', + 'Default' => null, + 'Extra' => '', + 'Privileges' => 'select,insert,update,references', + 'Comment' => '', + ], + ]); + + DB::shouldReceive('select') + ->once() + ->with('SHOW INDEX FROM `users`') + ->andReturn([ + (object) [ + 'Key_name' => 'PRIMARY', + 'Non_unique' => 0, + 'Index_type' => 'BTREE', + 'Column_name' => 'id', + 'Seq_in_index' => 1, + 'Collation' => 'A', + 'Cardinality' => 1, + 'Sub_part' => null, + 'Null' => '', + 'Comment' => '', + ], + (object) [ + 'Key_name' => 'users_email_unique', + 'Non_unique' => 0, + 'Index_type' => 'BTREE', + 'Column_name' => 'email', + 'Seq_in_index' => 1, + 'Collation' => 'A', + 'Cardinality' => 1, + 'Sub_part' => null, + 'Null' => '', + 'Comment' => '', + ], + ]); + + $tool = new DescribeTable(); + $response = $tool->handle(new Request(['table' => 'users'])); + $data = json_decode($response->getContent(), true, flags: JSON_THROW_ON_ERROR); + + $this->assertSame('users', $data['table']); + $this->assertCount(2, $data['columns']); + $this->assertSame('id', $data['columns'][0]['field']); + $this->assertSame('bigint unsigned', $data['columns'][0]['type']); + $this->assertSame('PRIMARY', $data['indexes'][0]['name']); + $this->assertSame(['id'], array_column($data['indexes'][0]['columns'], 'name')); + } + + public function test_handle_rejects_invalid_table_names(): void + { + $tool = new DescribeTable(); + $response = $tool->handle(new Request(['table' => 'users; DROP TABLE users'])); + $data = json_decode($response->getContent(), true, flags: JSON_THROW_ON_ERROR); + + $this->assertSame('VALIDATION_ERROR', $data['code']); + $this->assertStringContainsString('Invalid table name', $data['error']); + } + + public function test_handle_blocks_system_tables(): void + { + Config::set('mcp.database.blocked_tables', []); + + $tool = new DescribeTable(); + $response = $tool->handle(new Request(['table' => 'information_schema'])); + $data = json_decode($response->getContent(), true, flags: JSON_THROW_ON_ERROR); + + $this->assertSame('VALIDATION_ERROR', $data['code']); + $this->assertStringContainsString('not permitted', $data['error']); + } +}