Skip to content

Commit bb86da7

Browse files
authored
PHPLARA-253: Create database-info Boost tool as alternative to database-schema (#3544)
* Create schema tool for Boost Instead of using the default boost schema tool, we can register our own tool in the service provider. The default boost schema tool only supports SQL flavored databases and will not run properly for MongoDB connections. * Register tool in service provider This will make it available in Boost after `artistan boost:install` * Create third-party Boost guidelines To encourage Boost to prefer our own database tools for MongoDB connections
1 parent 847d713 commit bb86da7

7 files changed

Lines changed: 458 additions & 0 deletions

File tree

composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
},
5757
"require-dev": {
5858
"doctrine/coding-standard": "^12.0",
59+
"laravel/boost": "^2.4",
5960
"laravel/scout": "^10.3",
6061
"league/flysystem-gridfs": "^3.28",
6162
"league/flysystem-read-only": "^3.0",
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# MongoDB Laravel
2+
3+
This application uses the [MongoDB Laravel](https://github.com/mongodb/laravel-mongodb) package, so it likely has one or more `mongodb` driver connections. Boost's database tools assume a SQL database and won't work against those.
4+
5+
## Before using Boost's database tools
6+
7+
Before running any Boost database tool, check whether the target connection uses the `mongodb` driver. If the driver isn't already known, use Boost's `database-connections` tool to find out.
8+
9+
If it does, you MUST use a MongoDB Laravel equivalent tool instead, if available:
10+
11+
| Boost tool | MongoDB Laravel tool |
12+
|-------------------|----------------------|
13+
| `database-schema` | `database-info` |

src/MongoDBServiceProvider.php

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use Illuminate\Session\SessionManager;
1515
use Illuminate\Support\ServiceProvider;
1616
use InvalidArgumentException;
17+
use Laravel\Boost\BoostServiceProvider;
1718
use Laravel\Scout\EngineManager;
1819
use League\Flysystem\Filesystem;
1920
use League\Flysystem\GridFS\GridFSAdapter;
@@ -24,11 +25,14 @@
2425
use MongoDB\Laravel\Queue\MongoConnector;
2526
use MongoDB\Laravel\Scout\ScoutEngine;
2627
use MongoDB\Laravel\Session\MongoDbSessionHandler;
28+
use MongoDB\Laravel\Tools\DatabaseInfo;
2729
use Override;
2830
use RuntimeException;
2931

32+
use function array_merge;
3033
use function assert;
3134
use function class_exists;
35+
use function config;
3236
use function get_debug_type;
3337
use function is_string;
3438
use function sprintf;
@@ -103,6 +107,7 @@ public function register()
103107

104108
$this->registerFlysystemAdapter();
105109
$this->registerScoutEngine();
110+
$this->registerBoostTools();
106111
}
107112

108113
private function registerFlysystemAdapter(): void
@@ -157,6 +162,18 @@ private function registerFlysystemAdapter(): void
157162
});
158163
}
159164

165+
private function registerBoostTools(): void
166+
{
167+
if (! class_exists(BoostServiceProvider::class)) {
168+
return;
169+
}
170+
171+
config()->set('boost.mcp.tools.include', array_merge(
172+
config('boost.mcp.tools.include', []),
173+
[DatabaseInfo::class],
174+
));
175+
}
176+
160177
private function registerScoutEngine(): void
161178
{
162179
$this->app->resolving(EngineManager::class, function (EngineManager $engineManager) {

src/Tools/DatabaseInfo.php

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace MongoDB\Laravel\Tools;
6+
7+
use Illuminate\Contracts\JsonSchema\JsonSchema;
8+
use Illuminate\JsonSchema\Types\Type;
9+
use Illuminate\Support\Facades\Cache;
10+
use Illuminate\Support\Facades\DB;
11+
use Illuminate\Support\Facades\Log;
12+
use Laravel\Mcp\Request;
13+
use Laravel\Mcp\Response;
14+
use Laravel\Mcp\Server\Tool;
15+
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
16+
use MongoDB\Database;
17+
use MongoDB\Laravel\Connection;
18+
use MongoDB\Model\CollectionInfo;
19+
use Throwable;
20+
21+
use function assert;
22+
use function rescue;
23+
use function sprintf;
24+
use function str_contains;
25+
use function strtolower;
26+
27+
/**
28+
* MongoDB equivalent of Boost's DatabaseSchema tool.
29+
*/
30+
#[IsReadOnly]
31+
class DatabaseInfo extends Tool
32+
{
33+
/**
34+
* The tool's description.
35+
*/
36+
protected string $description = 'Read information about a MongoDB database. MongoDB does not use tables and columns, so this returns a summary of the collections. Use "summary" mode first (the default) to get an overview (collection names, types, and estimated document counts), then call again with "summary" set to false and a "filter" to get full details for specific collections (indexes and options). Only use this tool with a connection you already know uses the "mongodb" driver. Params: "connection" (required) - the name of a MongoDB database connection to inspect; "summary" (default true) - collection names, types, and estimated document counts only; "filter" - substring match on collection names.';
37+
38+
/**
39+
* Get the tool's input schema.
40+
*
41+
* @return array<string, Type>
42+
*/
43+
public function schema(JsonSchema $schema): array
44+
{
45+
return [
46+
'summary' => $schema->boolean()
47+
->description('Return only collection names, types, and estimated document counts. Use this first to understand the database, then request full details for specific collections using "filter". Defaults to false.'),
48+
'connection' => $schema->string()
49+
->description('Name of the MongoDB database connection to inspect. Must be a connection that uses the "mongodb" driver.')
50+
->required(),
51+
'filter' => $schema->string()
52+
->description('Filter collections by name (substring match).'),
53+
];
54+
}
55+
56+
/**
57+
* Handle the tool request.
58+
*/
59+
public function handle(Request $request): Response
60+
{
61+
$summary = (bool) $request->get('summary', false);
62+
$connectionName = (string) $request->get('connection');
63+
$filter = (string) ($request->get('filter') ?? '');
64+
65+
$connection = DB::connection($connectionName);
66+
67+
if (! $connection instanceof Connection) {
68+
return Response::error(sprintf('The [%s] connection is not a MongoDB connection.', $connectionName));
69+
}
70+
71+
$cacheKey = sprintf(
72+
'mongodb:mcp:database-info:%s:%s:%d',
73+
$connectionName,
74+
$filter,
75+
(int) $summary,
76+
);
77+
78+
try {
79+
$info = rescue(
80+
fn (): array => Cache::remember($cacheKey, 20, fn (): array => $this->getDatabaseInfo($connection, $connectionName, $filter, $summary)),
81+
fn (): array => $this->getDatabaseInfo($connection, $connectionName, $filter, $summary),
82+
report: false,
83+
);
84+
} catch (Throwable $exception) {
85+
Log::error('Failed to read information for MongoDB connection: ' . $connectionName, [
86+
'error' => $exception->getMessage(),
87+
]);
88+
89+
return Response::error(sprintf('Failed to read information for the [%s] connection.', $connectionName));
90+
}
91+
92+
return Response::json($info);
93+
}
94+
95+
/** @return array{database: string, connection: string, collections: array<string, mixed>} */
96+
protected function getDatabaseInfo(Connection $connection, string $connectionName, string $filter, bool $summary): array
97+
{
98+
$database = $connection->getDatabase();
99+
100+
$collections = [];
101+
102+
foreach ($database->listCollections() as $collectionInfo) {
103+
assert($collectionInfo instanceof CollectionInfo);
104+
$name = $collectionInfo->getName();
105+
106+
if ($filter !== '' && ! str_contains(strtolower($name), strtolower($filter))) {
107+
continue;
108+
}
109+
110+
$collections[$name] = $summary
111+
? $this->getCollectionSummary($database, $collectionInfo)
112+
: $this->getCollectionDetails($database, $collectionInfo);
113+
}
114+
115+
return [
116+
'database' => $database->getDatabaseName(),
117+
'connection' => $connectionName,
118+
'collections' => $collections,
119+
];
120+
}
121+
122+
/** @return array{type: string, estimated_document_count: int} */
123+
protected function getCollectionSummary(Database $database, CollectionInfo $collectionInfo): array
124+
{
125+
return [
126+
'type' => $collectionInfo->getType(),
127+
'estimated_document_count' => $this->estimatedDocumentCount($database, $collectionInfo->getName()),
128+
];
129+
}
130+
131+
/** @return array<string, mixed> */
132+
protected function getCollectionDetails(Database $database, CollectionInfo $collectionInfo): array
133+
{
134+
$name = $collectionInfo->getName();
135+
136+
try {
137+
return [
138+
'type' => $collectionInfo->getType(),
139+
'estimated_document_count' => $this->estimatedDocumentCount($database, $name),
140+
'options' => $collectionInfo->getOptions(),
141+
'indexes' => $this->getIndexes($database, $name),
142+
];
143+
} catch (Throwable $exception) {
144+
Log::error('Failed to get details for MongoDB collection: ' . $name, [
145+
'error' => $exception->getMessage(),
146+
]);
147+
148+
return ['error' => sprintf('Failed to get details for collection [%s].', $name)];
149+
}
150+
}
151+
152+
protected function estimatedDocumentCount(Database $database, string $collection): int
153+
{
154+
try {
155+
return $database->getCollection($collection)->estimatedDocumentCount();
156+
} catch (Throwable) {
157+
return 0;
158+
}
159+
}
160+
161+
/** @return array<string, array{keys: array<string, mixed>, unique: bool}> */
162+
protected function getIndexes(Database $database, string $collection): array
163+
{
164+
$indexes = [];
165+
166+
foreach ($database->getCollection($collection)->listIndexes() as $index) {
167+
$indexes[$index->getName()] = [
168+
'keys' => $index->getKey(),
169+
'unique' => $index->isUnique(),
170+
];
171+
}
172+
173+
return $indexes;
174+
}
175+
}

tests/Tools/DatabaseInfoTest.php

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace MongoDB\Laravel\Tests\Tools;
6+
7+
use ArrayIterator;
8+
use Illuminate\Database\ConnectionInterface;
9+
use Illuminate\Support\Facades\DB;
10+
use Mockery;
11+
use Mockery\MockInterface;
12+
use MongoDB\Collection;
13+
use MongoDB\Database;
14+
use MongoDB\Laravel\Connection;
15+
use MongoDB\Laravel\Tests\TestCase;
16+
use MongoDB\Laravel\Tools\DatabaseInfo;
17+
use MongoDB\Model\CollectionInfo;
18+
use MongoDB\Model\IndexInfo;
19+
use RuntimeException;
20+
21+
class DatabaseInfoTest extends TestCase
22+
{
23+
use InteractsWithTools;
24+
25+
public function testSummaryListsCollectionsWithCounts(): void
26+
{
27+
$this->fakeMongoConnection(
28+
collections: [new CollectionInfo(['name' => 'books', 'type' => 'collection'])],
29+
counts: ['books' => 3],
30+
);
31+
32+
$info = $this->toolJson(new DatabaseInfo(), ['connection' => 'mongodb', 'summary' => true]);
33+
34+
$this->assertSame('unittest', $info['database']);
35+
$this->assertSame('mongodb', $info['connection']);
36+
37+
$collection = $info['collections']['books'];
38+
$this->assertSame('collection', $collection['type']);
39+
$this->assertSame(3, $collection['estimated_document_count']);
40+
$this->assertArrayNotHasKey('indexes', $collection);
41+
}
42+
43+
public function testFilterExcludesNonMatchingCollections(): void
44+
{
45+
$this->fakeMongoConnection(
46+
collections: [
47+
new CollectionInfo(['name' => 'books', 'type' => 'collection']),
48+
new CollectionInfo(['name' => 'authors', 'type' => 'collection']),
49+
],
50+
counts: ['books' => 1, 'authors' => 1],
51+
);
52+
53+
$info = $this->toolJson(new DatabaseInfo(), ['filter' => 'book']);
54+
55+
$this->assertArrayHasKey('books', $info['collections']);
56+
$this->assertArrayNotHasKey('authors', $info['collections']);
57+
}
58+
59+
public function testFullDetailsIncludeIndexesAndOptions(): void
60+
{
61+
$this->fakeMongoConnection(
62+
collections: [new CollectionInfo(['name' => 'books', 'type' => 'collection', 'options' => ['capped' => true]])],
63+
counts: ['books' => 1],
64+
indexes: ['books' => [new IndexInfo(['name' => '_id_', 'key' => ['_id' => 1], 'v' => 2])]],
65+
);
66+
67+
$info = $this->toolJson(new DatabaseInfo());
68+
69+
$collection = $info['collections']['books'];
70+
$this->assertSame('collection', $collection['type']);
71+
$this->assertSame(1, $collection['estimated_document_count']);
72+
$this->assertSame(['capped' => true], $collection['options']);
73+
$this->assertSame(['_id' => 1], $collection['indexes']['_id_']['keys']);
74+
$this->assertFalse($collection['indexes']['_id_']['unique']);
75+
}
76+
77+
public function testErrorsWhenConnectionIsNotMongoDB(): void
78+
{
79+
DB::shouldReceive('connection')->andReturn(Mockery::mock(ConnectionInterface::class));
80+
81+
$response = $this->runTool(new DatabaseInfo(), ['connection' => 'sqlite']);
82+
83+
$this->assertToolHasError($response);
84+
$this->assertToolTextContains($response, 'The [sqlite] connection is not a MongoDB connection.');
85+
}
86+
87+
public function testReturnsErrorWhenListingCollectionsFails(): void
88+
{
89+
$database = Mockery::mock(Database::class);
90+
$database->shouldReceive('listCollections')->andThrow(new RuntimeException('super sekrit server error'));
91+
92+
$connection = Mockery::mock(Connection::class);
93+
$connection->shouldReceive('getDatabase')->andReturn($database);
94+
95+
DB::shouldReceive('connection')->andReturn($connection);
96+
97+
$response = $this->runTool(new DatabaseInfo(), ['connection' => 'mongodb']);
98+
99+
$this->assertToolHasError($response);
100+
$this->assertToolTextContains($response, 'Failed to read information for the [mongodb] connection.');
101+
// The underlying driver message must not leak to the caller.
102+
$this->assertStringNotContainsString('super sekrit', (string) $response->content());
103+
}
104+
105+
/**
106+
* Bind a fully mocked MongoDB connection so the tool never touches a real server.
107+
*
108+
* @param list<CollectionInfo> $collections
109+
* @param array<string, int> $counts collection name => estimated document count
110+
* @param array<string, list<IndexInfo>> $indexes collection name => indexes
111+
*/
112+
private function fakeMongoConnection(array $collections, array $counts, array $indexes = []): void
113+
{
114+
$database = Mockery::mock(Database::class);
115+
$database->shouldReceive('getDatabaseName')->andReturn('unittest');
116+
$database->shouldReceive('listCollections')->andReturn(new ArrayIterator($collections));
117+
118+
$database->shouldReceive('getCollection')->andReturnUsing(
119+
function (string $name) use ($counts, $indexes): MockInterface {
120+
$collection = Mockery::mock(Collection::class);
121+
$collection->shouldReceive('estimatedDocumentCount')->andReturn($counts[$name] ?? 0);
122+
$collection->shouldReceive('listIndexes')->andReturn(new ArrayIterator($indexes[$name] ?? []));
123+
124+
return $collection;
125+
},
126+
);
127+
128+
$connection = Mockery::mock(Connection::class);
129+
$connection->shouldReceive('getDatabase')->andReturn($database);
130+
131+
DB::shouldReceive('connection')->andReturn($connection);
132+
}
133+
}

0 commit comments

Comments
 (0)