Add describe_table MCP tool
This commit is contained in:
parent
a45bc388b5
commit
20caaebc21
4 changed files with 337 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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' => [],
|
||||
|
|
|
|||
151
src/php/src/Mcp/Tools/DescribeTable.php
Normal file
151
src/php/src/Mcp/Tools/DescribeTable.php
Normal 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',
|
||||
]));
|
||||
}
|
||||
}
|
||||
113
src/php/tests/Unit/DescribeTableTest.php
Normal file
113
src/php/tests/Unit/DescribeTableTest.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue