Add describe_table MCP tool

This commit is contained in:
Virgil 2026-04-01 09:55:50 +00:00
parent a45bc388b5
commit 20caaebc21
4 changed files with 337 additions and 0 deletions

View file

@ -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

View file

@ -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' => [],

View file

@ -0,0 +1,151 @@
<?php
declare(strict_types=1);
namespace Core\Mcp\Tools;
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;
class DescribeTable extends Tool
{
protected string $description = 'Describe a database table, including columns and indexes';
public function handle(Request $request): Response
{
$table = trim((string) $request->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<string, mixed> $column
* @return array<string, mixed>
*/
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<int, object> $indexes
* @return array<int, array<string, mixed>>
*/
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',
]));
}
}

View file

@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace Core\Mcp\Tests\Unit;
use Core\Mcp\Tools\DescribeTable;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\DB;
use Laravel\Mcp\Request;
use Mockery;
use Tests\TestCase;
class DescribeTableTest extends TestCase
{
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
public function test_handle_returns_columns_and_indexes_for_a_table(): void
{
DB::shouldReceive('select')
->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']);
}
}