Skip to content

Commit e383678

Browse files
committed
feat: remove external CLI tool requirement from database schema tool
1 parent c9c409e commit e383678

File tree

11 files changed

+640
-94
lines changed

11 files changed

+640
-94
lines changed

src/Install/GuidelineAssist.php

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Laravel\Boost\Install;
6+
7+
use Illuminate\Database\Eloquent\Model;
8+
use ReflectionClass;
9+
use Symfony\Component\Finder\Finder;
10+
11+
class GuidelineAssist
12+
{
13+
/** @var array<string, string> */
14+
protected array $modelPaths = [];
15+
16+
protected array $controllerPaths = [];
17+
18+
protected array $enumPaths = [];
19+
20+
protected static array $classes = [];
21+
22+
public function __construct()
23+
{
24+
$this->modelPaths = $this->discover(fn ($reflection) => ($reflection->isSubclassOf(Model::class) && ! $reflection->isAbstract()));
25+
$this->controllerPaths = $this->discover(fn (ReflectionClass $reflection) => (stripos($reflection->getName(), 'controller') !== false || stripos($reflection->getNamespaceName(), 'controller') !== false));
26+
$this->enumPaths = $this->discover(fn ($reflection) => $reflection->isEnum());
27+
}
28+
29+
/**
30+
* @return array<string, string> - className, absolutePath
31+
*/
32+
public function models(): array
33+
{
34+
return $this->modelPaths;
35+
}
36+
37+
/**
38+
* @return array<string, string> - className, absolutePath
39+
*/
40+
public function controllers(): array
41+
{
42+
return $this->controllerPaths;
43+
}
44+
45+
/**
46+
* @return array<string, string> - className, absolutePath
47+
*/
48+
public function enums(): array
49+
{
50+
return $this->enumPaths;
51+
}
52+
53+
/**
54+
* Discover all Eloquent models in the application.
55+
*
56+
* @return array<string, string>
57+
*/
58+
private function discover(callable $cb): array
59+
{
60+
$classes = [];
61+
$appPath = app_path();
62+
63+
if (! is_dir($appPath)) {
64+
return ['app-path-isnt-a-directory' => $appPath];
65+
}
66+
67+
if (empty(self::$classes)) {
68+
$finder = Finder::create()
69+
->in($appPath)
70+
->files()
71+
->name('*.php');
72+
73+
foreach ($finder as $file) {
74+
$relativePath = $file->getRelativePathname();
75+
$namespace = app()->getNamespace();
76+
$className = $namespace.str_replace(
77+
['/', '.php'],
78+
['\\', ''],
79+
$relativePath
80+
);
81+
82+
try {
83+
if (class_exists($className)) {
84+
self::$classes[$className] = $appPath.DIRECTORY_SEPARATOR.$relativePath;
85+
}
86+
} catch (\Throwable) {
87+
// Ignore exceptions and errors from class loading/reflection
88+
}
89+
}
90+
}
91+
92+
foreach (self::$classes as $className => $path) {
93+
if ($cb(new ReflectionClass($className))) {
94+
$classes[$className] = $path;
95+
}
96+
}
97+
98+
return $classes;
99+
}
100+
101+
public function shouldEnforceStrictTypes(): bool
102+
{
103+
if (empty($this->modelPaths)) {
104+
return false;
105+
}
106+
107+
return str_contains(
108+
file_get_contents(current($this->modelPaths)),
109+
'strict_types=1'
110+
);
111+
}
112+
113+
public function enumContents(): string
114+
{
115+
if (empty($this->enumPaths)) {
116+
return '';
117+
}
118+
119+
return file_get_contents(current($this->enumPaths));
120+
}
121+
}

src/Mcp/Tools/ApplicationInfo.php

Lines changed: 3 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -4,22 +4,18 @@
44

55
namespace Laravel\Boost\Mcp\Tools;
66

7-
use Illuminate\Database\Eloquent\Model;
7+
use Laravel\Boost\Install\GuidelineAssist;
88
use Laravel\Mcp\Server\Tool;
99
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
1010
use Laravel\Mcp\Server\Tools\ToolInputSchema;
1111
use Laravel\Mcp\Server\Tools\ToolResult;
1212
use Laravel\Roster\Package;
1313
use Laravel\Roster\Roster;
14-
use ReflectionClass;
15-
use Symfony\Component\Finder\Finder;
1614

1715
#[IsReadOnly]
1816
class ApplicationInfo extends Tool
1917
{
20-
public function __construct(protected Roster $roster)
21-
{
22-
}
18+
public function __construct(protected Roster $roster, protected GuidelineAssist $guidelineAssist) {}
2319

2420
public function description(): string
2521
{
@@ -41,50 +37,7 @@ public function handle(array $arguments): ToolResult
4137
'laravel_version' => app()->version(),
4238
'database_engine' => config('database.default'),
4339
'packages' => $this->roster->packages()->map(fn (Package $package) => ['roster_name' => $package->name(), 'version' => $package->version(), 'package_name' => $package->rawName()]),
44-
'models' => $this->discoverModels(),
40+
'models' => array_keys($this->guidelineAssist->models()),
4541
]);
4642
}
47-
48-
/**
49-
* Discover all Eloquent models in the application.
50-
*
51-
* @return array<string, string>
52-
*/
53-
private function discoverModels(): array
54-
{
55-
$models = [];
56-
$appPath = app_path();
57-
58-
if (! is_dir($appPath)) {
59-
return ['app-path-isnt-a-directory' => $appPath];
60-
}
61-
62-
$finder = Finder::create()
63-
->in($appPath)
64-
->files()
65-
->name('*.php');
66-
67-
foreach ($finder as $file) {
68-
$relativePath = $file->getRelativePathname();
69-
$namespace = app()->getNamespace();
70-
$className = $namespace.str_replace(
71-
['/', '.php'],
72-
['\\', ''],
73-
$relativePath
74-
);
75-
76-
try {
77-
if (class_exists($className)) {
78-
$reflection = new ReflectionClass($className);
79-
if ($reflection->isSubclassOf(Model::class) && ! $reflection->isAbstract()) {
80-
$models[$className] = $appPath.DIRECTORY_SEPARATOR.$relativePath;
81-
}
82-
}
83-
} catch (\Throwable) {
84-
// Ignore exceptions and errors from class loading/reflection
85-
}
86-
}
87-
88-
return $models;
89-
}
9043
}

src/Mcp/Tools/DatabaseSchema.php

Lines changed: 126 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,15 @@
44

55
namespace Laravel\Boost\Mcp\Tools;
66

7-
use Illuminate\Console\Command;
8-
use Illuminate\Support\Facades\Artisan;
97
use Illuminate\Support\Facades\Cache;
10-
use Illuminate\Support\Str;
8+
use Illuminate\Support\Facades\DB;
9+
use Illuminate\Support\Facades\Log;
10+
use Illuminate\Support\Facades\Schema;
11+
use Laravel\Boost\Mcp\Tools\DatabaseSchema\SchemaDriverFactory;
1112
use Laravel\Mcp\Server\Tool;
1213
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
1314
use Laravel\Mcp\Server\Tools\ToolInputSchema;
1415
use Laravel\Mcp\Server\Tools\ToolResult;
15-
use Symfony\Component\Console\Output\BufferedOutput;
1616

1717
#[IsReadOnly()]
1818
class DatabaseSchema extends Tool
@@ -28,6 +28,10 @@ public function schema(ToolInputSchema $schema): ToolInputSchema
2828
->description('Name of the database connection to dump (defaults to app\'s default connection, often not needed)')
2929
->required(false);
3030

31+
$schema->string('filter')
32+
->description('Filter the tables by name')
33+
->required(false);
34+
3135
return $schema;
3236
}
3337

@@ -37,35 +41,132 @@ public function schema(ToolInputSchema $schema): ToolInputSchema
3741
public function handle(array $arguments): ToolResult
3842
{
3943
$connection = $arguments['database'] ?? config('database.default');
40-
$cacheKey = "boost:mcp:database-schema:{$connection}";
44+
$filter = $arguments['filter'] ?? '';
45+
$cacheKey = "boost:mcp:database-schema:{$connection}:{$filter}";
46+
47+
$schema = Cache::remember($cacheKey, 20, function () use ($connection, $filter) {
48+
return $this->getDatabaseStructure($connection, $filter);
49+
});
50+
51+
return ToolResult::json($schema);
52+
}
53+
54+
protected function getDatabaseStructure(?string $connection, string $filter = ''): array
55+
{
56+
$structure = [
57+
'engine' => DB::connection($connection)->getDriverName(),
58+
'tables' => $this->getAllTablesStructure($connection, $filter),
59+
'global' => $this->getGlobalStructure($connection),
60+
];
61+
62+
return $structure;
63+
}
4164

42-
// We can't cache for long in case the user rolls back, edits a migration
43-
// then migrates, and gets the schema again
44-
$schema = Cache::remember($cacheKey, 20, function () use ($arguments) {
45-
$filename = 'tmp_'.Str::random(40).'.sql';
46-
$path = database_path("schema/{$filename}");
65+
protected function getAllTablesStructure(?string $connection, string $filter = ''): array
66+
{
67+
$structures = [];
4768

48-
$artisanArgs = ['--path' => $path];
69+
foreach ($this->getAllTables($connection) as $table) {
70+
$tableName = $table['name'];
4971

50-
// Respect optional connection name
51-
if (! empty($arguments['database'])) {
52-
$artisanArgs['--database'] = $arguments['database'];
72+
if ($filter && ! str_contains(strtolower($tableName), strtolower($filter))) {
73+
continue;
5374
}
5475

55-
$output = new BufferedOutput;
56-
$result = Artisan::call('schema:dump', $artisanArgs, $output);
57-
if ($result !== Command::SUCCESS) {
58-
return ToolResult::error('Failed to dump database schema: '.$output->fetch());
76+
$structures[$tableName] = $this->getTableStructure($connection, $tableName);
77+
}
78+
79+
return $structures;
80+
}
81+
82+
protected function getAllTables(?string $connection): array
83+
{
84+
return Schema::connection($connection)->getTables();
85+
}
86+
87+
protected function getTableStructure(?string $connection, string $tableName): array
88+
{
89+
$driver = SchemaDriverFactory::make($connection);
90+
91+
try {
92+
$columns = $this->getTableColumns($connection, $tableName);
93+
$indexes = $this->getTableIndexes($connection, $tableName);
94+
$foreignKeys = $this->getTableForeignKeys($connection, $tableName);
95+
$triggers = $driver->getTriggers($tableName);
96+
$checkConstraints = $driver->getCheckConstraints($tableName);
97+
98+
return [
99+
'columns' => $columns,
100+
'indexes' => $indexes,
101+
'foreign_keys' => $foreignKeys,
102+
'triggers' => $triggers,
103+
'check_constraints' => $checkConstraints,
104+
];
105+
} catch (\Exception $e) {
106+
Log::error('Failed to get table structure for: '.$tableName, [
107+
'error' => $e->getMessage(),
108+
'trace' => $e->getTraceAsString(),
109+
]);
110+
111+
return [
112+
'error' => 'Failed to get structure: '.$e->getMessage(),
113+
];
114+
}
115+
}
116+
117+
protected function getTableColumns(?string $connection, string $tableName): array
118+
{
119+
$columns = Schema::connection($connection)->getColumnListing($tableName);
120+
$columnDetails = [];
121+
122+
foreach ($columns as $column) {
123+
$columnDetails[$column] = [
124+
'type' => Schema::connection($connection)->getColumnType($tableName, $column),
125+
];
126+
}
127+
128+
return $columnDetails;
129+
}
130+
131+
protected function getTableIndexes(?string $connection, string $tableName): array
132+
{
133+
try {
134+
$indexes = Schema::connection($connection)->getIndexes($tableName);
135+
$indexDetails = [];
136+
137+
foreach ($indexes as $index) {
138+
$indexDetails[$index['name']] = [
139+
'columns' => $index['columns'],
140+
'type' => $index['type'] ?? null,
141+
'is_unique' => $index['unique'] ?? false,
142+
'is_primary' => $index['primary'] ?? false,
143+
];
59144
}
60145

61-
$schemaContent = file_get_contents($path);
146+
return $indexDetails;
147+
} catch (\Exception $e) {
148+
return [];
149+
}
150+
}
62151

63-
// Clean up temp file
64-
unlink($path);
152+
protected function getTableForeignKeys(?string $connection, string $tableName): array
153+
{
154+
try {
155+
return Schema::connection($connection)->getForeignKeys($tableName);
156+
} catch (\Exception $e) {
157+
return [];
158+
}
159+
}
65160

66-
return $schemaContent;
67-
});
161+
protected function getGlobalStructure(?string $connection): array
162+
{
163+
$driver = SchemaDriverFactory::make($connection);
68164

69-
return ToolResult::text($schema);
165+
return [
166+
'views' => $driver->getViews(),
167+
'stored_procedures' => $driver->getStoredProcedures(),
168+
'functions' => $driver->getFunctions(),
169+
'sequences' => $driver->getSequences(),
170+
];
70171
}
71-
}
172+
}

0 commit comments

Comments
 (0)