Skip to content

Commit 2f68e74

Browse files
committed
feat: adding the McpTools facade
1 parent 2ee2ac5 commit 2f68e74

File tree

7 files changed

+865
-707
lines changed

7 files changed

+865
-707
lines changed

src/MCP/Bootstrap/BootMcpTools.php

Lines changed: 333 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,333 @@
1+
<?php
2+
3+
namespace Binaryk\LaravelRestify\MCP\Bootstrap;
4+
5+
use Binaryk\LaravelRestify\Actions\Action;
6+
use Binaryk\LaravelRestify\Getters\Getter;
7+
use Binaryk\LaravelRestify\MCP\Concerns\HasMcpTools;
8+
use Binaryk\LaravelRestify\MCP\Requests\McpActionRequest;
9+
use Binaryk\LaravelRestify\MCP\Requests\McpGetterRequest;
10+
use Binaryk\LaravelRestify\MCP\Tools\Operations\ActionTool;
11+
use Binaryk\LaravelRestify\MCP\Tools\Operations\DeleteTool;
12+
use Binaryk\LaravelRestify\MCP\Tools\Operations\GetterTool;
13+
use Binaryk\LaravelRestify\MCP\Tools\Operations\IndexTool;
14+
use Binaryk\LaravelRestify\MCP\Tools\Operations\ProfileTool;
15+
use Binaryk\LaravelRestify\MCP\Tools\Operations\ShowTool;
16+
use Binaryk\LaravelRestify\MCP\Tools\Operations\StoreTool;
17+
use Binaryk\LaravelRestify\MCP\Tools\Operations\UpdateTool;
18+
use Binaryk\LaravelRestify\Repositories\Repository;
19+
use Binaryk\LaravelRestify\Restify;
20+
use Illuminate\Support\Collection;
21+
use Illuminate\Support\Facades\Cache;
22+
23+
/**
24+
* Bootstrap MCP tools discovery.
25+
* Contains all the heavy lifting for discovering tools from various sources.
26+
*/
27+
class BootMcpTools
28+
{
29+
/**
30+
* Bootstrap and discover all MCP tools.
31+
*/
32+
public function boot(): array
33+
{
34+
// In tests, skip caching to avoid database cache table issues
35+
if (app()->environment('testing')) {
36+
return collect()
37+
->merge($this->discoverCustomTools())
38+
->merge($this->discoverRepositoryTools())
39+
->values()
40+
->toArray();
41+
}
42+
43+
// Cache key includes mode to prevent cache pollution between modes
44+
$mode = config('restify.mcp.mode', 'direct');
45+
$cacheKey = "restify.mcp.all_tools_metadata.{$mode}";
46+
47+
$tools = Cache::remember($cacheKey, 3600, function (): array {
48+
return collect()
49+
->merge($this->discoverCustomTools())
50+
->merge($this->discoverRepositoryTools())
51+
->values()
52+
->toArray();
53+
});
54+
55+
return $tools;
56+
}
57+
58+
/**
59+
* Discover custom tools from src/MCP/Tools and app/Restify/Mcp/Tools.
60+
*/
61+
protected function discoverCustomTools(): Collection
62+
{
63+
$tools = collect();
64+
$excludedTools = config('restify.mcp.tools.exclude', []);
65+
66+
// Discover from src/MCP/Tools/*.php
67+
$toolDir = new \DirectoryIterator(__DIR__.'/../Tools');
68+
foreach ($toolDir as $toolFile) {
69+
if ($toolFile->isFile() && $toolFile->getExtension() === 'php') {
70+
$fqdn = 'Binaryk\\LaravelRestify\\MCP\\Tools\\'.$toolFile->getBasename('.php');
71+
if (class_exists($fqdn) && ! in_array($fqdn, $excludedTools, true)) {
72+
$instance = app($fqdn);
73+
$tools->push([
74+
'type' => 'custom',
75+
'name' => $instance->name(),
76+
'title' => $instance->title(),
77+
'description' => $instance->description(),
78+
'class' => $fqdn,
79+
'instance' => $instance,
80+
'category' => 'Custom Tools',
81+
]);
82+
}
83+
}
84+
}
85+
86+
// Discover wrapper tools from src/MCP/Tools/Wrapper/*.php
87+
$wrapperDir = __DIR__.'/../Tools/Wrapper';
88+
if (is_dir($wrapperDir)) {
89+
$wrapperToolDir = new \DirectoryIterator($wrapperDir);
90+
foreach ($wrapperToolDir as $toolFile) {
91+
if ($toolFile->isFile() && $toolFile->getExtension() === 'php') {
92+
$fqdn = 'Binaryk\\LaravelRestify\\MCP\\Tools\\Wrapper\\'.$toolFile->getBasename('.php');
93+
if (class_exists($fqdn) && ! in_array($fqdn, $excludedTools, true)) {
94+
$instance = app($fqdn);
95+
$tools->push([
96+
'type' => 'wrapper',
97+
'name' => $instance->name(),
98+
'title' => $instance->title(),
99+
'description' => $instance->description(),
100+
'class' => $fqdn,
101+
'instance' => $instance,
102+
'category' => 'Wrapper Tools',
103+
]);
104+
}
105+
}
106+
}
107+
}
108+
109+
// Discover from app/Restify/Mcp/Tools
110+
$appToolsPath = app_path('Restify/Mcp/Tools');
111+
if (is_dir($appToolsPath)) {
112+
$appToolDir = new \DirectoryIterator($appToolsPath);
113+
foreach ($appToolDir as $toolFile) {
114+
if ($toolFile->isFile() && $toolFile->getExtension() === 'php') {
115+
$fqdn = 'App\\Restify\\Mcp\\Tools\\'.$toolFile->getBasename('.php');
116+
if (class_exists($fqdn) && ! in_array($fqdn, $excludedTools, true)) {
117+
$instance = app($fqdn);
118+
$tools->push([
119+
'type' => 'custom',
120+
'name' => $instance->name(),
121+
'title' => $instance->title(),
122+
'description' => $instance->description(),
123+
'class' => $fqdn,
124+
'instance' => $instance,
125+
'category' => 'Custom Tools',
126+
]);
127+
}
128+
}
129+
}
130+
}
131+
132+
// Extra tools from config
133+
$extraTools = config('restify.mcp.tools.include', []);
134+
foreach ($extraTools as $toolClass) {
135+
if (class_exists($toolClass)) {
136+
$instance = app($toolClass);
137+
$tools->push([
138+
'type' => 'custom',
139+
'name' => $instance->name(),
140+
'title' => $instance->title(),
141+
'description' => $instance->description(),
142+
'class' => $toolClass,
143+
'instance' => $instance,
144+
'category' => 'Custom Tools',
145+
]);
146+
}
147+
}
148+
149+
return $tools;
150+
}
151+
152+
/**
153+
* Discover all repository tools (CRUD operations, actions, getters).
154+
*/
155+
protected function discoverRepositoryTools(): Collection
156+
{
157+
return collect(Restify::$repositories)
158+
->filter(fn (string $repo): bool => in_array(HasMcpTools::class, class_uses_recursive($repo)))
159+
->flatMap(fn (string $repoClass): Collection => $this->discoverRepositoryOperations($repoClass))
160+
->values();
161+
}
162+
163+
/**
164+
* Discover all operations (CRUD, actions, getters) for a specific repository.
165+
*/
166+
protected function discoverRepositoryOperations(string $repositoryClass): Collection
167+
{
168+
$repository = app($repositoryClass);
169+
$tools = collect();
170+
171+
// Profile tool (only for users repository)
172+
if ($repository::uriKey() === 'users') {
173+
$instance = new ProfileTool($repositoryClass);
174+
$tools->push([
175+
'type' => 'profile',
176+
'name' => $instance->name(),
177+
'title' => $instance->title(),
178+
'description' => $instance->description(),
179+
'class' => ProfileTool::class,
180+
'instance' => $instance,
181+
'repository' => $repository::uriKey(),
182+
'category' => 'Profile',
183+
]);
184+
}
185+
186+
// Index operation
187+
if (method_exists($repository, 'mcpAllowsIndex') && $repository->mcpAllowsIndex()) {
188+
$instance = new IndexTool($repositoryClass);
189+
$tools->push([
190+
'type' => 'index',
191+
'name' => $instance->name(),
192+
'title' => $instance->title(),
193+
'description' => $instance->description(),
194+
'class' => IndexTool::class,
195+
'instance' => $instance,
196+
'repository' => $repository::uriKey(),
197+
'category' => 'CRUD Operations',
198+
]);
199+
}
200+
201+
// Show operation
202+
if (method_exists($repository, 'mcpAllowsShow') && $repository->mcpAllowsShow()) {
203+
$instance = new ShowTool($repositoryClass);
204+
$tools->push([
205+
'type' => 'show',
206+
'name' => $instance->name(),
207+
'title' => $instance->title(),
208+
'description' => $instance->description(),
209+
'class' => ShowTool::class,
210+
'instance' => $instance,
211+
'repository' => $repository::uriKey(),
212+
'category' => 'CRUD Operations',
213+
]);
214+
}
215+
216+
// Store operation
217+
if (method_exists($repository, 'mcpAllowsStore') && $repository->mcpAllowsStore()) {
218+
$instance = new StoreTool($repositoryClass);
219+
$tools->push([
220+
'type' => 'store',
221+
'name' => $instance->name(),
222+
'title' => $instance->title(),
223+
'description' => $instance->description(),
224+
'class' => StoreTool::class,
225+
'instance' => $instance,
226+
'repository' => $repository::uriKey(),
227+
'category' => 'CRUD Operations',
228+
]);
229+
}
230+
231+
// Update operation
232+
if (method_exists($repository, 'mcpAllowsUpdate') && $repository->mcpAllowsUpdate()) {
233+
$instance = new UpdateTool($repositoryClass);
234+
$tools->push([
235+
'type' => 'update',
236+
'name' => $instance->name(),
237+
'title' => $instance->title(),
238+
'description' => $instance->description(),
239+
'class' => UpdateTool::class,
240+
'instance' => $instance,
241+
'repository' => $repository::uriKey(),
242+
'category' => 'CRUD Operations',
243+
]);
244+
}
245+
246+
// Delete operation
247+
if (method_exists($repository, 'mcpAllowsDelete') && $repository->mcpAllowsDelete()) {
248+
$instance = new DeleteTool($repositoryClass);
249+
$tools->push([
250+
'type' => 'delete',
251+
'name' => $instance->name(),
252+
'title' => $instance->title(),
253+
'description' => $instance->description(),
254+
'class' => DeleteTool::class,
255+
'instance' => $instance,
256+
'repository' => $repository::uriKey(),
257+
'category' => 'CRUD Operations',
258+
]);
259+
}
260+
261+
// Actions
262+
if (method_exists($repository, 'mcpAllowsActions') && $repository->mcpAllowsActions()) {
263+
$tools = $tools->merge($this->discoverActions($repositoryClass, $repository));
264+
}
265+
266+
// Getters
267+
if (method_exists($repository, 'mcpAllowsGetters') && $repository->mcpAllowsGetters()) {
268+
$tools = $tools->merge($this->discoverGetters($repositoryClass, $repository));
269+
}
270+
271+
return $tools;
272+
}
273+
274+
/**
275+
* Discover all actions for a repository.
276+
*/
277+
protected function discoverActions(string $repositoryClass, Repository $repository): Collection
278+
{
279+
$actionRequest = app(McpActionRequest::class);
280+
281+
return $repository->resolveActions($actionRequest)
282+
->filter(fn ($action): bool => $action instanceof Action)
283+
->filter(fn (Action $action): bool => $action->isShownOnMcp($actionRequest, $repository))
284+
->filter(fn (Action $action): bool => $action->authorizedToSee($actionRequest))
285+
->unique(fn (Action $action): string => $action->uriKey())
286+
->map(function (Action $action) use ($repositoryClass, $repository): array {
287+
$instance = new ActionTool($repositoryClass, $action);
288+
289+
return [
290+
'type' => 'action',
291+
'name' => $instance->name(),
292+
'title' => $instance->title(),
293+
'description' => $instance->description(),
294+
'class' => ActionTool::class,
295+
'instance' => $instance,
296+
'repository' => $repository::uriKey(),
297+
'category' => 'Actions',
298+
'action' => $action,
299+
];
300+
})
301+
->values();
302+
}
303+
304+
/**
305+
* Discover all getters for a repository.
306+
*/
307+
protected function discoverGetters(string $repositoryClass, Repository $repository): Collection
308+
{
309+
$getterRequest = app(McpGetterRequest::class);
310+
311+
return $repository->resolveGetters($getterRequest)
312+
->filter(fn ($getter): bool => $getter instanceof Getter)
313+
->filter(fn (Getter $getter): bool => $getter->isShownOnMcp($getterRequest, $repository))
314+
->filter(fn (Getter $getter): bool => $getter->authorizedToSee($getterRequest))
315+
->unique(fn (Getter $getter): string => $getter->uriKey())
316+
->map(function (Getter $getter) use ($repositoryClass, $repository): array {
317+
$instance = new GetterTool($repositoryClass, $getter);
318+
319+
return [
320+
'type' => 'getter',
321+
'name' => $instance->name(),
322+
'title' => $instance->title(),
323+
'description' => $instance->description(),
324+
'class' => GetterTool::class,
325+
'instance' => $instance,
326+
'repository' => $repository::uriKey(),
327+
'category' => 'Getters',
328+
'getter' => $getter,
329+
];
330+
})
331+
->values();
332+
}
333+
}

src/MCP/McpTools.php

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<?php
2+
3+
namespace Binaryk\LaravelRestify\MCP;
4+
5+
use Illuminate\Support\Facades\Facade;
6+
7+
/**
8+
* Facade for MCP tools discovery and management.
9+
*
10+
* @method static \Illuminate\Support\Collection all()
11+
* @method static \Illuminate\Support\Collection category(string $category)
12+
* @method static \Illuminate\Support\Collection repository(string $repositoryKey)
13+
* @method static array|null find(string $name)
14+
* @method static void register(array $tools)
15+
* @method static bool canUse(string|object $tool)
16+
* @method static \Illuminate\Support\Collection authorized()
17+
* @method static void setServer(\Binaryk\LaravelRestify\MCP\RestifyServer $server)
18+
* @method static \Binaryk\LaravelRestify\MCP\RestifyServer|null server()
19+
* @method static void clear()
20+
* @method static \Illuminate\Support\Collection byCategory()
21+
* @method static \Illuminate\Support\Collection byRepository()
22+
* @method static void rediscover()
23+
*
24+
* @see \Binaryk\LaravelRestify\MCP\McpToolsManager
25+
*/
26+
class McpTools extends Facade
27+
{
28+
/**
29+
* Get the registered name of the component.
30+
*/
31+
protected static function getFacadeAccessor(): string
32+
{
33+
return McpToolsManager::class;
34+
}
35+
}

0 commit comments

Comments
 (0)