From 2f68e74720b265b8dd5b3efea460133806ff5489 Mon Sep 17 00:00:00 2001 From: Eduard Lupacescu Date: Sun, 26 Oct 2025 22:17:25 +0200 Subject: [PATCH 1/7] feat: adding the McpTools facade --- src/MCP/Bootstrap/BootMcpTools.php | 333 ++++++++++ src/MCP/McpTools.php | 35 + src/MCP/McpToolsManager.php | 179 +++++ src/MCP/RestifyServer.php | 244 ++++--- src/MCP/Services/ToolRegistry.php | 770 +++++----------------- src/RestifyApplicationServiceProvider.php | 7 + tests/MCP/WrapperToolsIntegrationTest.php | 4 +- 7 files changed, 865 insertions(+), 707 deletions(-) create mode 100644 src/MCP/Bootstrap/BootMcpTools.php create mode 100644 src/MCP/McpTools.php create mode 100644 src/MCP/McpToolsManager.php diff --git a/src/MCP/Bootstrap/BootMcpTools.php b/src/MCP/Bootstrap/BootMcpTools.php new file mode 100644 index 00000000..84bfe37d --- /dev/null +++ b/src/MCP/Bootstrap/BootMcpTools.php @@ -0,0 +1,333 @@ +environment('testing')) { + return collect() + ->merge($this->discoverCustomTools()) + ->merge($this->discoverRepositoryTools()) + ->values() + ->toArray(); + } + + // Cache key includes mode to prevent cache pollution between modes + $mode = config('restify.mcp.mode', 'direct'); + $cacheKey = "restify.mcp.all_tools_metadata.{$mode}"; + + $tools = Cache::remember($cacheKey, 3600, function (): array { + return collect() + ->merge($this->discoverCustomTools()) + ->merge($this->discoverRepositoryTools()) + ->values() + ->toArray(); + }); + + return $tools; + } + + /** + * Discover custom tools from src/MCP/Tools and app/Restify/Mcp/Tools. + */ + protected function discoverCustomTools(): Collection + { + $tools = collect(); + $excludedTools = config('restify.mcp.tools.exclude', []); + + // Discover from src/MCP/Tools/*.php + $toolDir = new \DirectoryIterator(__DIR__.'/../Tools'); + foreach ($toolDir as $toolFile) { + if ($toolFile->isFile() && $toolFile->getExtension() === 'php') { + $fqdn = 'Binaryk\\LaravelRestify\\MCP\\Tools\\'.$toolFile->getBasename('.php'); + if (class_exists($fqdn) && ! in_array($fqdn, $excludedTools, true)) { + $instance = app($fqdn); + $tools->push([ + 'type' => 'custom', + 'name' => $instance->name(), + 'title' => $instance->title(), + 'description' => $instance->description(), + 'class' => $fqdn, + 'instance' => $instance, + 'category' => 'Custom Tools', + ]); + } + } + } + + // Discover wrapper tools from src/MCP/Tools/Wrapper/*.php + $wrapperDir = __DIR__.'/../Tools/Wrapper'; + if (is_dir($wrapperDir)) { + $wrapperToolDir = new \DirectoryIterator($wrapperDir); + foreach ($wrapperToolDir as $toolFile) { + if ($toolFile->isFile() && $toolFile->getExtension() === 'php') { + $fqdn = 'Binaryk\\LaravelRestify\\MCP\\Tools\\Wrapper\\'.$toolFile->getBasename('.php'); + if (class_exists($fqdn) && ! in_array($fqdn, $excludedTools, true)) { + $instance = app($fqdn); + $tools->push([ + 'type' => 'wrapper', + 'name' => $instance->name(), + 'title' => $instance->title(), + 'description' => $instance->description(), + 'class' => $fqdn, + 'instance' => $instance, + 'category' => 'Wrapper Tools', + ]); + } + } + } + } + + // Discover from app/Restify/Mcp/Tools + $appToolsPath = app_path('Restify/Mcp/Tools'); + if (is_dir($appToolsPath)) { + $appToolDir = new \DirectoryIterator($appToolsPath); + foreach ($appToolDir as $toolFile) { + if ($toolFile->isFile() && $toolFile->getExtension() === 'php') { + $fqdn = 'App\\Restify\\Mcp\\Tools\\'.$toolFile->getBasename('.php'); + if (class_exists($fqdn) && ! in_array($fqdn, $excludedTools, true)) { + $instance = app($fqdn); + $tools->push([ + 'type' => 'custom', + 'name' => $instance->name(), + 'title' => $instance->title(), + 'description' => $instance->description(), + 'class' => $fqdn, + 'instance' => $instance, + 'category' => 'Custom Tools', + ]); + } + } + } + } + + // Extra tools from config + $extraTools = config('restify.mcp.tools.include', []); + foreach ($extraTools as $toolClass) { + if (class_exists($toolClass)) { + $instance = app($toolClass); + $tools->push([ + 'type' => 'custom', + 'name' => $instance->name(), + 'title' => $instance->title(), + 'description' => $instance->description(), + 'class' => $toolClass, + 'instance' => $instance, + 'category' => 'Custom Tools', + ]); + } + } + + return $tools; + } + + /** + * Discover all repository tools (CRUD operations, actions, getters). + */ + protected function discoverRepositoryTools(): Collection + { + return collect(Restify::$repositories) + ->filter(fn (string $repo): bool => in_array(HasMcpTools::class, class_uses_recursive($repo))) + ->flatMap(fn (string $repoClass): Collection => $this->discoverRepositoryOperations($repoClass)) + ->values(); + } + + /** + * Discover all operations (CRUD, actions, getters) for a specific repository. + */ + protected function discoverRepositoryOperations(string $repositoryClass): Collection + { + $repository = app($repositoryClass); + $tools = collect(); + + // Profile tool (only for users repository) + if ($repository::uriKey() === 'users') { + $instance = new ProfileTool($repositoryClass); + $tools->push([ + 'type' => 'profile', + 'name' => $instance->name(), + 'title' => $instance->title(), + 'description' => $instance->description(), + 'class' => ProfileTool::class, + 'instance' => $instance, + 'repository' => $repository::uriKey(), + 'category' => 'Profile', + ]); + } + + // Index operation + if (method_exists($repository, 'mcpAllowsIndex') && $repository->mcpAllowsIndex()) { + $instance = new IndexTool($repositoryClass); + $tools->push([ + 'type' => 'index', + 'name' => $instance->name(), + 'title' => $instance->title(), + 'description' => $instance->description(), + 'class' => IndexTool::class, + 'instance' => $instance, + 'repository' => $repository::uriKey(), + 'category' => 'CRUD Operations', + ]); + } + + // Show operation + if (method_exists($repository, 'mcpAllowsShow') && $repository->mcpAllowsShow()) { + $instance = new ShowTool($repositoryClass); + $tools->push([ + 'type' => 'show', + 'name' => $instance->name(), + 'title' => $instance->title(), + 'description' => $instance->description(), + 'class' => ShowTool::class, + 'instance' => $instance, + 'repository' => $repository::uriKey(), + 'category' => 'CRUD Operations', + ]); + } + + // Store operation + if (method_exists($repository, 'mcpAllowsStore') && $repository->mcpAllowsStore()) { + $instance = new StoreTool($repositoryClass); + $tools->push([ + 'type' => 'store', + 'name' => $instance->name(), + 'title' => $instance->title(), + 'description' => $instance->description(), + 'class' => StoreTool::class, + 'instance' => $instance, + 'repository' => $repository::uriKey(), + 'category' => 'CRUD Operations', + ]); + } + + // Update operation + if (method_exists($repository, 'mcpAllowsUpdate') && $repository->mcpAllowsUpdate()) { + $instance = new UpdateTool($repositoryClass); + $tools->push([ + 'type' => 'update', + 'name' => $instance->name(), + 'title' => $instance->title(), + 'description' => $instance->description(), + 'class' => UpdateTool::class, + 'instance' => $instance, + 'repository' => $repository::uriKey(), + 'category' => 'CRUD Operations', + ]); + } + + // Delete operation + if (method_exists($repository, 'mcpAllowsDelete') && $repository->mcpAllowsDelete()) { + $instance = new DeleteTool($repositoryClass); + $tools->push([ + 'type' => 'delete', + 'name' => $instance->name(), + 'title' => $instance->title(), + 'description' => $instance->description(), + 'class' => DeleteTool::class, + 'instance' => $instance, + 'repository' => $repository::uriKey(), + 'category' => 'CRUD Operations', + ]); + } + + // Actions + if (method_exists($repository, 'mcpAllowsActions') && $repository->mcpAllowsActions()) { + $tools = $tools->merge($this->discoverActions($repositoryClass, $repository)); + } + + // Getters + if (method_exists($repository, 'mcpAllowsGetters') && $repository->mcpAllowsGetters()) { + $tools = $tools->merge($this->discoverGetters($repositoryClass, $repository)); + } + + return $tools; + } + + /** + * Discover all actions for a repository. + */ + protected function discoverActions(string $repositoryClass, Repository $repository): Collection + { + $actionRequest = app(McpActionRequest::class); + + return $repository->resolveActions($actionRequest) + ->filter(fn ($action): bool => $action instanceof Action) + ->filter(fn (Action $action): bool => $action->isShownOnMcp($actionRequest, $repository)) + ->filter(fn (Action $action): bool => $action->authorizedToSee($actionRequest)) + ->unique(fn (Action $action): string => $action->uriKey()) + ->map(function (Action $action) use ($repositoryClass, $repository): array { + $instance = new ActionTool($repositoryClass, $action); + + return [ + 'type' => 'action', + 'name' => $instance->name(), + 'title' => $instance->title(), + 'description' => $instance->description(), + 'class' => ActionTool::class, + 'instance' => $instance, + 'repository' => $repository::uriKey(), + 'category' => 'Actions', + 'action' => $action, + ]; + }) + ->values(); + } + + /** + * Discover all getters for a repository. + */ + protected function discoverGetters(string $repositoryClass, Repository $repository): Collection + { + $getterRequest = app(McpGetterRequest::class); + + return $repository->resolveGetters($getterRequest) + ->filter(fn ($getter): bool => $getter instanceof Getter) + ->filter(fn (Getter $getter): bool => $getter->isShownOnMcp($getterRequest, $repository)) + ->filter(fn (Getter $getter): bool => $getter->authorizedToSee($getterRequest)) + ->unique(fn (Getter $getter): string => $getter->uriKey()) + ->map(function (Getter $getter) use ($repositoryClass, $repository): array { + $instance = new GetterTool($repositoryClass, $getter); + + return [ + 'type' => 'getter', + 'name' => $instance->name(), + 'title' => $instance->title(), + 'description' => $instance->description(), + 'class' => GetterTool::class, + 'instance' => $instance, + 'repository' => $repository::uriKey(), + 'category' => 'Getters', + 'getter' => $getter, + ]; + }) + ->values(); + } +} diff --git a/src/MCP/McpTools.php b/src/MCP/McpTools.php new file mode 100644 index 00000000..8910e30f --- /dev/null +++ b/src/MCP/McpTools.php @@ -0,0 +1,35 @@ + + */ + protected array $discoveredTools = []; + + /** + * The MCP server instance. + */ + protected ?RestifyServer $server = null; + + /** + * Whether tools have been discovered. + */ + protected bool $discovered = false; + + public function __construct( + protected BootMcpTools $bootstrap + ) {} + + /** + * Get all discovered tools. + * Automatically discovers tools if not already done. + */ + public function all(): Collection + { + if (! $this->discovered) { + $this->discover(); + } + + return collect($this->discoveredTools); + } + + /** + * Discover all tools from all sources. + */ + protected function discover(): void + { + $tools = $this->bootstrap->boot(); + $this->register($tools); + $this->discovered = true; + } + + /** + * Register discovered tools. + * + * @param array $tools + */ + public function register(array $tools): void + { + foreach ($tools as $tool) { + $this->discoveredTools[$tool['name']] = $tool; + } + } + + /** + * Get tools for a specific category. + */ + public function category(string $category): Collection + { + return $this->all()->where('category', $category); + } + + /** + * Get tools for a specific repository. + */ + public function repository(string $repositoryKey): Collection + { + return $this->all()->where('repository', $repositoryKey); + } + + /** + * Find a tool by name. + */ + public function find(string $name): ?array + { + return $this->all()->firstWhere('name', $name); + } + + /** + * Check if user can use a tool (delegates to server's canUseTool method). + */ + public function canUse(string|object $tool): bool + { + return $this->server()?->canUseTool($tool) ?? true; + } + + /** + * Get authorized tools for current user (filtered by permissions). + */ + public function authorized(): Collection + { + return $this->all()->filter(fn (array $tool): bool => $this->canUse($tool['instance'])); + } + + /** + * Set the server instance. + */ + public function setServer(RestifyServer $server): void + { + $this->server = $server; + } + + /** + * Get the server instance. + */ + public function server(): ?RestifyServer + { + return $this->server; + } + + /** + * Clear all discovered tools and cache. + */ + public function clear(): void + { + $this->discoveredTools = []; + $this->discovered = false; + $this->server = null; + + // Clear cache for both modes + Cache::forget('restify.mcp.all_tools_metadata.direct'); + Cache::forget('restify.mcp.all_tools_metadata.wrapper'); + Cache::forget('restify.mcp.all_tools_metadata'); // Legacy key + } + + /** + * Get tools grouped by category. + */ + public function byCategory(): Collection + { + return $this->all()->groupBy('category'); + } + + /** + * Get tools grouped by repository. + */ + public function byRepository(): Collection + { + return $this->all() + ->filter(fn (array $tool): bool => isset($tool['repository'])) + ->groupBy('repository'); + } + + /** + * Force rediscovery of tools (useful for testing). + */ + public function rediscover(): void + { + $this->clear(); + $this->discover(); + } +} diff --git a/src/MCP/RestifyServer.php b/src/MCP/RestifyServer.php index 448d0c86..c00f1165 100644 --- a/src/MCP/RestifyServer.php +++ b/src/MCP/RestifyServer.php @@ -4,8 +4,81 @@ namespace Binaryk\LaravelRestify\MCP; +/** + * PERMISSION SYSTEM USAGE + * ======================= + * + * This server provides fine-grained permission control for MCP tools using token-based authorization. + * + * ## Token Creation UI + * + * To display all available tools in your token creation UI: + * + * ```php + * $server = app(RestifyServer::class); + * $allTools = $server->getAllAvailableTools(); + * + * // Returns ALL tools regardless of mode (direct or wrapper): + * // - In "direct" mode: Repository CRUD operations, actions, getters, custom tools + * // - In "wrapper" mode: 4 wrapper tools + all repository operations, actions, getters, custom tools + * // + * // User can then select which tools to grant access to for this token. + * ``` + * + * ## Implementing Permission Checks + * + * Override `canUseTool()` in your application's MCP server: + * + * ```php + * // app/Mcp/ApplicationServer.php + * class ApplicationServer extends \Binaryk\LaravelRestify\MCP\RestifyServer + * { + * public function canUseTool(string|object $tool): bool + * { + * // Get tool name + * $toolName = is_string($tool) ? $tool : $tool->name(); + * + * // Get current token from request + * $token = request()->bearerToken(); + * $mcpToken = McpToken::where('token', hash('sha256', $token))->first(); + * + * if (!$mcpToken) { + * return false; + * } + * + * // Check if token has permission for this tool + * // $mcpToken->allowed_tools is JSON array like: ["users-index", "posts-store", "posts-update-status-action"] + * return in_array($toolName, $mcpToken->allowed_tools ?? [], true); + * } + * } + * ``` + * + * ## How Permissions Work in Wrapper Mode + * + * Even though wrapper mode only registers 4 wrapper tools with the MCP server, + * ALL individual operations are still discovered and available for permission checks: + * + * 1. AI calls wrapper tool: `execute-operation(repository="posts", operation="store")` + * 2. Wrapper tool looks up "posts-store" in McpTools + * 3. Before execution, calls `canUseTool("posts-store")` + * 4. Your `canUseTool()` implementation checks if token has "posts-store" permission + * 5. If yes, executes; if no, throws AuthorizationException + * + * This allows fine-grained permissions even in wrapper mode! + * + * ## Getting Authorized Tools at Runtime + * + * To get only tools the current user/token can access: + * + * ```php + * $authorizedTools = $server->getAuthorizedTools(); + * // Returns tools filtered by canUseTool() + * ``` + */ + use Binaryk\LaravelRestify\Actions\Action; use Binaryk\LaravelRestify\Getters\Getter; +use Binaryk\LaravelRestify\MCP\Bootstrap\BootMcpTools; use Binaryk\LaravelRestify\MCP\Concerns\HasMcpTools; use Binaryk\LaravelRestify\MCP\Requests\McpActionRequest; use Binaryk\LaravelRestify\MCP\Requests\McpGetterRequest; @@ -18,6 +91,10 @@ use Binaryk\LaravelRestify\MCP\Tools\Operations\ShowTool; use Binaryk\LaravelRestify\MCP\Tools\Operations\StoreTool; use Binaryk\LaravelRestify\MCP\Tools\Operations\UpdateTool; +use Binaryk\LaravelRestify\MCP\Tools\Wrapper\DiscoverRepositoriesTool; +use Binaryk\LaravelRestify\MCP\Tools\Wrapper\ExecuteOperationTool; +use Binaryk\LaravelRestify\MCP\Tools\Wrapper\GetOperationDetailsTool; +use Binaryk\LaravelRestify\MCP\Tools\Wrapper\GetRepositoryOperationsTool; use Binaryk\LaravelRestify\Repositories\Repository; use Binaryk\LaravelRestify\Restify; use Laravel\Mcp\Server; @@ -74,6 +151,11 @@ class RestifyServer extends Server protected function boot(): void { + // Set this server instance in McpTools facade + // Tools will be auto-discovered on first access via McpTools::all() + McpTools::setServer($this); + + // Register tools with the server collect($this->discoverTools())->each(fn (string $tool): string => $this->tools[] = $tool); $this->discoverRepositoryTools(); collect($this->discoverResources())->each(fn (string $resource): string => $this->resources[] = $resource); @@ -85,42 +167,10 @@ protected function boot(): void */ protected function discoverTools(): array { - $tools = []; - - $excludedTools = config('restify.mcp.tools.exclude', []); - $toolDir = new \DirectoryIterator(__DIR__.DIRECTORY_SEPARATOR.'Tools'); - - foreach ($toolDir as $toolFile) { - if ($toolFile->isFile() && $toolFile->getExtension() === 'php') { - $fqdn = 'Binaryk\\LaravelRestify\\MCP\\Tools\\'.$toolFile->getBasename('.php'); - if (class_exists($fqdn) && ! in_array($fqdn, $excludedTools, true)) { - $tools[] = $fqdn; - } - } - } - - // Auto-discover tools from app/Restify/Mcp/Tools - $appToolsPath = app_path('Restify/Mcp/Tools'); - if (is_dir($appToolsPath)) { - $appToolDir = new \DirectoryIterator($appToolsPath); - foreach ($appToolDir as $toolFile) { - if ($toolFile->isFile() && $toolFile->getExtension() === 'php') { - $fqdn = 'App\\Restify\\Mcp\\Tools\\'.$toolFile->getBasename('.php'); - if (class_exists($fqdn) && ! in_array($fqdn, $excludedTools, true)) { - $tools[] = $fqdn; - } - } - } - } - - $extraTools = config('restify.mcp.tools.include', []); - foreach ($extraTools as $toolClass) { - if (class_exists($toolClass)) { - $tools[] = $toolClass; - } - } - - return $tools; + return McpTools::category('Custom Tools') + ->filter(fn (array $tool): bool => $this->canUseTool($tool['instance'])) + ->pluck('class') + ->toArray(); } protected function discoverRepositoryTools(): void @@ -133,46 +183,10 @@ protected function discoverRepositoryTools(): void } // Direct mode - register each operation as a separate tool - collect(Restify::$repositories) - ->filter(function (string $repository) { - return in_array(HasMcpTools::class, class_uses_recursive($repository)); - }) - ->each(function (string $repository) { - $repositoryInstance = app($repository); - - // if it's for User repository, add the ProfileTool - if ($repositoryInstance::uriKey() === 'users') { - $this->tools[] = new ProfileTool($repository); - } - - if (method_exists($repositoryInstance, 'mcpAllowsIndex') && $repositoryInstance->mcpAllowsIndex()) { - $this->tools[] = new IndexTool($repository); - } - - if (method_exists($repositoryInstance, 'mcpAllowsShow') && $repositoryInstance->mcpAllowsShow()) { - $this->tools[] = new ShowTool($repository); - } - - if (method_exists($repositoryInstance, 'mcpAllowsStore') && $repositoryInstance->mcpAllowsStore()) { - $this->tools[] = new StoreTool($repository); - } - - if (method_exists($repositoryInstance, 'mcpAllowsUpdate') && $repositoryInstance->mcpAllowsUpdate()) { - $this->tools[] = new UpdateTool($repository); - } - - if (method_exists($repositoryInstance, 'mcpAllowsDelete') && $repositoryInstance->mcpAllowsDelete()) { - $this->tools[] = new DeleteTool($repository); - } - - if (method_exists($repositoryInstance, 'mcpAllowsActions') && $repositoryInstance->mcpAllowsActions()) { - $this->discoverActionsForRepository($repository, $repositoryInstance); - } - - if (method_exists($repositoryInstance, 'mcpAllowsGetters') && $repositoryInstance->mcpAllowsGetters()) { - $this->discoverGettersForRepository($repository, $repositoryInstance); - } - }); + McpTools::all() + ->whereIn('category', ['CRUD Operations', 'Actions', 'Getters', 'Profile']) + ->filter(fn (array $tool): bool => $this->canUseTool($tool['instance'])) + ->each(fn (array $tool) => $this->tools[] = $tool['instance']); } /** @@ -180,34 +194,10 @@ protected function discoverRepositoryTools(): void */ protected function registerWrapperTools(): void { - $this->tools[] = \Binaryk\LaravelRestify\MCP\Tools\Wrapper\DiscoverRepositoriesTool::class; - $this->tools[] = \Binaryk\LaravelRestify\MCP\Tools\Wrapper\GetRepositoryOperationsTool::class; - $this->tools[] = \Binaryk\LaravelRestify\MCP\Tools\Wrapper\GetOperationDetailsTool::class; - $this->tools[] = \Binaryk\LaravelRestify\MCP\Tools\Wrapper\ExecuteOperationTool::class; - } - - protected function discoverActionsForRepository(string $repositoryClass, Repository $repositoryInstance): void - { - $actionRequest = app(McpActionRequest::class); - - $repositoryInstance->resolveActions($actionRequest) - ->filter(fn ($action) => $action instanceof Action) - ->filter(fn (Action $action) => $action->isShownOnMcp($actionRequest, $repositoryInstance)) - ->filter(fn (Action $action) => $action->authorizedToSee($actionRequest)) - ->unique(fn (Action $action) => $action->uriKey()) // Avoid duplicates - ->each(fn (Action $action) => $this->tools[] = new ActionTool($repositoryClass, $action)); - } - - protected function discoverGettersForRepository(string $repositoryClass, Repository $repositoryInstance): void - { - $getterRequest = app(McpGetterRequest::class); - - $repositoryInstance->resolveGetters($getterRequest) - ->filter(fn ($getter) => $getter instanceof Getter) - ->filter(fn (Getter $getter) => $getter->isShownOnMcp($getterRequest, $repositoryInstance)) - ->filter(fn (Getter $getter) => $getter->authorizedToSee($getterRequest)) - ->unique(fn (Getter $getter) => $getter->uriKey()) // Avoid duplicates - ->each(fn (Getter $getter) => $this->tools[] = new GetterTool($repositoryClass, $getter)); + $this->tools[] = DiscoverRepositoriesTool::class; + $this->tools[] = GetRepositoryOperationsTool::class; + $this->tools[] = GetOperationDetailsTool::class; + $this->tools[] = ExecuteOperationTool::class; } /** @@ -282,4 +272,50 @@ protected function discoverPrompts(): array return $prompts; } + + /** + * Override this method in your app server to implement permission checks. + */ + public function canUseTool(string|object $tool): bool + { + return true; + } + + /** + * Get all available tools with metadata (unfiltered by permissions). + * Useful for token creation UI where you want to show all possible tools. + * + * IMPORTANT: This returns ALL discovered tools regardless of mode: + * - In "direct" mode: All repository operations, actions, getters, custom tools + * - In "wrapper" mode: The 4 wrapper tools + all repository operations, actions, getters, custom tools + * + * Even though only 4 wrapper tools are registered with the MCP server in wrapper mode, + * all individual operations are still discovered and available for permission checks. + * This allows you to create tokens with fine-grained permissions for specific operations. + */ + public function getAllAvailableTools(): \Illuminate\Support\Collection + { + return McpTools::all()->map(fn (array $tool): array => [ + 'name' => $tool['name'], + 'title' => $tool['title'], + 'description' => $tool['description'], + 'category' => $tool['category'], + 'type' => $tool['type'], + ]); + } + + /** + * Get tools that the current user/token is authorized to use. + */ + public function getAuthorizedTools(): \Illuminate\Support\Collection + { + return McpTools::authorized()->map(fn (array $tool): array => [ + 'name' => $tool['name'], + 'title' => $tool['title'], + 'description' => $tool['description'], + 'category' => $tool['category'], + 'type' => $tool['type'], + ]); + } + } diff --git a/src/MCP/Services/ToolRegistry.php b/src/MCP/Services/ToolRegistry.php index 76f76d69..b99c0651 100644 --- a/src/MCP/Services/ToolRegistry.php +++ b/src/MCP/Services/ToolRegistry.php @@ -4,7 +4,7 @@ use Binaryk\LaravelRestify\Actions\Action; use Binaryk\LaravelRestify\Getters\Getter; -use Binaryk\LaravelRestify\MCP\Concerns\HasMcpTools; +use Binaryk\LaravelRestify\MCP\McpTools; use Binaryk\LaravelRestify\MCP\Requests\McpActionRequest; use Binaryk\LaravelRestify\MCP\Requests\McpDestroyRequest; use Binaryk\LaravelRestify\MCP\Requests\McpGetterRequest; @@ -13,26 +13,53 @@ use Binaryk\LaravelRestify\MCP\Requests\McpShowRequest; use Binaryk\LaravelRestify\MCP\Requests\McpStoreRequest; use Binaryk\LaravelRestify\MCP\Requests\McpUpdateRequest; +use Binaryk\LaravelRestify\MCP\RestifyServer; use Binaryk\LaravelRestify\MCP\Tools\Operations\ProfileTool; use Binaryk\LaravelRestify\Repositories\Repository; -use Binaryk\LaravelRestify\Restify; use Illuminate\JsonSchema\JsonSchemaTypeFactory; use Illuminate\Support\Collection; -use Illuminate\Support\Facades\Cache; +use Laravel\Mcp\Request; use Laravel\Mcp\Response; class ToolRegistry { - protected string $cacheKey = 'restify.mcp.registry'; - - protected int $cacheTtl = 3600; + public function __construct( + protected ?RestifyServer $server = null + ) { + $this->server ??= McpTools::server(); + } /** * Get all available repositories with their MCP-enabled operations. */ public function getAvailableRepositories(?string $search = null): Collection { - $repositories = $this->buildRepositoriesMetadata(); + // Get all repository tools from McpTools facade + $repositories = McpTools::authorized() + ->filter(fn (array $tool): bool => isset($tool['repository'])) + ->groupBy('repository') + ->map(function (Collection $tools, string $repositoryKey): array { + $firstTool = $tools->first(); + + // Count operations by type + $operations = $tools->whereIn('type', ['index', 'show', 'store', 'update', 'delete', 'profile'])->pluck('type')->values()->toArray(); + $actionsCount = $tools->where('type', 'action')->count(); + $gettersCount = $tools->where('type', 'getter')->count(); + + // Get repository metadata from the first tool instance + $repository = app($firstTool['instance']->repository ?? Repository::class); + + return [ + 'name' => $repositoryKey, + 'label' => $firstTool['title'] ?? $repositoryKey, + 'description' => $firstTool['description'] ?? '', + 'model' => class_basename($repository::guessModelClassName()), + 'operations' => $operations, + 'actions_count' => $actionsCount, + 'getters_count' => $gettersCount, + ]; + }) + ->values(); if ($search) { $search = strtolower($search); @@ -51,679 +78,220 @@ public function getAvailableRepositories(?string $search = null): Collection */ public function getRepositoryOperations(string $repositoryKey): array { - $repositoryClass = $this->findRepositoryClass($repositoryKey); - - if (! $repositoryClass) { - throw new \InvalidArgumentException("Repository '{$repositoryKey}' not found"); - } - - if (! $this->hasRepositoryMcpTools($repositoryClass)) { - throw new \InvalidArgumentException("Repository '{$repositoryKey}' does not have MCP tools enabled. Add the HasMcpTools trait to enable MCP support."); - } - - $repository = app($repositoryClass); - - return [ - 'repository' => $repositoryKey, - 'label' => $repositoryClass::label(), - 'description' => $repositoryClass::description(app(McpRequest::class)), - 'operations' => $this->buildRepositoryOperations($repository, $repositoryClass), - 'actions' => $this->buildRepositoryActions($repository, $repositoryClass), - 'getters' => $this->buildRepositoryGetters($repository, $repositoryClass), - ]; - } - - /** - * Get detailed information about a specific operation. - */ - public function getOperationDetails(string $repositoryKey, string $operationType, ?string $operationName = null): array - { - $repositoryClass = $this->findRepositoryClass($repositoryKey); + // Get all tools for this repository from McpTools + $tools = McpTools::repository($repositoryKey); - if (! $repositoryClass) { - throw new \InvalidArgumentException("Repository '{$repositoryKey}' not found"); - } - - if (! $this->hasRepositoryMcpTools($repositoryClass)) { + if ($tools->isEmpty()) { throw new \InvalidArgumentException("Repository '{$repositoryKey}' does not have MCP tools enabled. Add the HasMcpTools trait to enable MCP support."); } - $repository = app($repositoryClass); - - return match ($operationType) { - 'index' => $this->getIndexOperationDetails($repository, $repositoryClass), - 'show' => $this->getShowOperationDetails($repository, $repositoryClass), - 'store' => $this->getStoreOperationDetails($repository, $repositoryClass), - 'update' => $this->getUpdateOperationDetails($repository, $repositoryClass), - 'delete' => $this->getDeleteOperationDetails($repository, $repositoryClass), - 'profile' => $this->getProfileOperationDetails($repository, $repositoryClass), - 'action' => $this->getActionOperationDetails($repository, $repositoryClass, $operationName), - 'getter' => $this->getGetterOperationDetails($repository, $repositoryClass, $operationName), - default => throw new \InvalidArgumentException("Invalid operation type: {$operationType}"), - }; - } - - /** - * Execute an operation with the provided parameters. - */ - public function executeOperation(string $repositoryKey, string $operationType, ?string $operationName, array $parameters): Response - { - $repositoryClass = $this->findRepositoryClass($repositoryKey); - - if (! $repositoryClass) { - throw new \InvalidArgumentException("Repository '{$repositoryKey}' not found"); - } - - if (! $this->hasRepositoryMcpTools($repositoryClass)) { - throw new \InvalidArgumentException("Repository '{$repositoryKey}' does not have MCP tools enabled. Add the HasMcpTools trait to enable MCP support."); - } - - $repository = app($repositoryClass); - - return match ($operationType) { - 'index' => $this->executeIndexOperation($repository, $parameters), - 'show' => $this->executeShowOperation($repository, $parameters), - 'store' => $this->executeStoreOperation($repository, $parameters), - 'update' => $this->executeUpdateOperation($repository, $parameters), - 'delete' => $this->executeDeleteOperation($repository, $parameters), - 'profile' => $this->executeProfileOperation($repository, $parameters), - 'action' => $this->executeActionOperation($repository, $repositoryClass, $operationName, $parameters), - 'getter' => $this->executeGetterOperation($repository, $repositoryClass, $operationName, $parameters), - default => throw new \InvalidArgumentException("Invalid operation type: {$operationType}"), - }; - } - - /** - * Build metadata for all repositories that have MCP tools enabled. - */ - protected function buildRepositoriesMetadata(): Collection - { - return Cache::remember($this->cacheKey, $this->cacheTtl, function () { - return collect(Restify::$repositories) - ->filter(fn ($repoClass) => $this->hasRepositoryMcpTools($repoClass)) - ->map(function ($repositoryClass) { - $repository = app($repositoryClass); - $operations = []; - - if ($repository::uriKey() === 'users') { - $operations[] = 'profile'; - } - - if (method_exists($repository, 'mcpAllowsIndex') && $repository->mcpAllowsIndex()) { - $operations[] = 'index'; - } - - if (method_exists($repository, 'mcpAllowsShow') && $repository->mcpAllowsShow()) { - $operations[] = 'show'; - } - - if (method_exists($repository, 'mcpAllowsStore') && $repository->mcpAllowsStore()) { - $operations[] = 'store'; - } - - if (method_exists($repository, 'mcpAllowsUpdate') && $repository->mcpAllowsUpdate()) { - $operations[] = 'update'; - } - - if (method_exists($repository, 'mcpAllowsDelete') && $repository->mcpAllowsDelete()) { - $operations[] = 'delete'; - } - - $actionsCount = 0; - $gettersCount = 0; - - if (method_exists($repository, 'mcpAllowsActions') && $repository->mcpAllowsActions()) { - $actionsCount = $this->countRepositoryActions($repository, $repositoryClass); - } - - if (method_exists($repository, 'mcpAllowsGetters') && $repository->mcpAllowsGetters()) { - $gettersCount = $this->countRepositoryGetters($repository, $repositoryClass); - } - - return [ - 'name' => $repository::uriKey(), - 'label' => $repositoryClass::label(), - 'description' => $repositoryClass::description(app(McpRequest::class)), - 'model' => class_basename($repositoryClass::guessModelClassName()), - 'operations' => $operations, - 'actions_count' => $actionsCount, - 'getters_count' => $gettersCount, - ]; - }) - ->values(); - }); - } - - protected function hasRepositoryMcpTools(string $repositoryClass): bool - { - return in_array(HasMcpTools::class, class_uses_recursive($repositoryClass)); - } - - protected function findRepositoryClass(string $repositoryKey): ?string - { - return collect(Restify::$repositories) - ->first(fn ($repoClass) => app($repoClass)::uriKey() === $repositoryKey); - } - - protected function buildRepositoryOperations(Repository $repository, string $repositoryClass): array - { - $operations = []; - - if ($repository::uriKey() === 'users') { - $operations[] = [ - 'type' => 'profile', - 'name' => 'profile-tool', - 'title' => 'Profile', - 'description' => 'Get the authenticated user profile', - ]; - } - - if (method_exists($repository, 'mcpAllowsIndex') && $repository->mcpAllowsIndex()) { - $operations[] = [ - 'type' => 'index', - 'name' => "{$repository::uriKey()}-index-tool", - 'title' => "{$repositoryClass::label()} Index", - 'description' => $repositoryClass::description(app(McpIndexRequest::class)), - ]; - } - - if (method_exists($repository, 'mcpAllowsShow') && $repository->mcpAllowsShow()) { - $operations[] = [ - 'type' => 'show', - 'name' => "{$repository::uriKey()}-show-tool", - 'title' => "{$repositoryClass::label()} Show", - 'description' => "Show a specific {$repositoryClass::label()} record", - ]; - } - - if (method_exists($repository, 'mcpAllowsStore') && $repository->mcpAllowsStore()) { - $operations[] = [ - 'type' => 'store', - 'name' => "{$repository::uriKey()}-store-tool", - 'title' => "{$repositoryClass::label()} Create", - 'description' => "Create a new {$repositoryClass::label()} record", - ]; - } + // Filter by permissions + $tools = $tools->filter(fn (array $tool): bool => McpTools::canUse($tool['instance'])); - if (method_exists($repository, 'mcpAllowsUpdate') && $repository->mcpAllowsUpdate()) { - $operations[] = [ - 'type' => 'update', - 'name' => "{$repository::uriKey()}-update-tool", - 'title' => "{$repositoryClass::label()} Update", - 'description' => "Update an existing {$repositoryClass::label()} record", - ]; + if ($tools->isEmpty()) { + throw new \InvalidArgumentException("Repository '{$repositoryKey}' found, but you don't have permission to access any of its operations"); } - if (method_exists($repository, 'mcpAllowsDelete') && $repository->mcpAllowsDelete()) { - $operations[] = [ - 'type' => 'delete', - 'name' => "{$repository::uriKey()}-delete-tool", - 'title' => "{$repositoryClass::label()} Delete", - 'description' => "Delete a {$repositoryClass::label()} record", - ]; - } + $firstTool = $tools->first(); - return $operations; - } - - protected function buildRepositoryActions(Repository $repository, string $repositoryClass): array - { - if (! method_exists($repository, 'mcpAllowsActions') || ! $repository->mcpAllowsActions()) { - return []; - } - - $actionRequest = app(McpActionRequest::class); + // Build operations list + $operations = $tools->whereIn('type', ['index', 'show', 'store', 'update', 'delete', 'profile']) + ->map(fn (array $tool): array => [ + 'type' => $tool['type'], + 'name' => $tool['name'], + 'title' => $tool['title'], + 'description' => $tool['description'], + ]) + ->values() + ->toArray(); - return $repository->resolveActions($actionRequest) - ->filter(fn ($action) => $action instanceof Action) - ->filter(fn (Action $action) => $action->isShownOnMcp($actionRequest, $repository)) - ->filter(fn (Action $action) => $action->authorizedToSee($actionRequest)) - ->unique(fn (Action $action) => $action->uriKey()) - ->map(fn (Action $action) => [ + // Build actions list + $actions = $tools->where('type', 'action') + ->map(fn (array $tool): array => [ 'type' => 'action', - 'name' => $action->uriKey(), - 'tool_name' => "{$repository::uriKey()}-{$action->uriKey()}-action-tool", - 'title' => $action->name(), - 'description' => $action->description($actionRequest) ?? "Execute {$action->name()} action", + 'name' => $tool['action']->uriKey(), + 'tool_name' => $tool['name'], + 'title' => $tool['title'], + 'description' => $tool['description'], ]) ->values() ->toArray(); - } - - protected function buildRepositoryGetters(Repository $repository, string $repositoryClass): array - { - if (! method_exists($repository, 'mcpAllowsGetters') || ! $repository->mcpAllowsGetters()) { - return []; - } - $getterRequest = app(McpGetterRequest::class); - - return $repository->resolveGetters($getterRequest) - ->filter(fn ($getter) => $getter instanceof Getter) - ->filter(fn (Getter $getter) => $getter->isShownOnMcp($getterRequest, $repository)) - ->filter(fn (Getter $getter) => $getter->authorizedToSee($getterRequest)) - ->unique(fn (Getter $getter) => $getter->uriKey()) - ->map(fn (Getter $getter) => [ + // Build getters list + $getters = $tools->where('type', 'getter') + ->map(fn (array $tool): array => [ 'type' => 'getter', - 'name' => $getter->uriKey(), - 'tool_name' => "{$repository::uriKey()}-{$getter->uriKey()}-getter-tool", - 'title' => $getter->name(), - 'description' => $getter->description($getterRequest) ?? "Execute {$getter->name()} getter", + 'name' => $tool['getter']->uriKey(), + 'tool_name' => $tool['name'], + 'title' => $tool['title'], + 'description' => $tool['description'], ]) ->values() ->toArray(); - } - - protected function countRepositoryActions(Repository $repository, string $repositoryClass): int - { - $actionRequest = app(McpActionRequest::class); - - return $repository->resolveActions($actionRequest) - ->filter(fn ($action) => $action instanceof Action) - ->filter(fn (Action $action) => $action->isShownOnMcp($actionRequest, $repository)) - ->filter(fn (Action $action) => $action->authorizedToSee($actionRequest)) - ->unique(fn (Action $action) => $action->uriKey()) - ->count(); - } - - protected function countRepositoryGetters(Repository $repository, string $repositoryClass): int - { - $getterRequest = app(McpGetterRequest::class); - - return $repository->resolveGetters($getterRequest) - ->filter(fn ($getter) => $getter instanceof Getter) - ->filter(fn (Getter $getter) => $getter->isShownOnMcp($getterRequest, $repository)) - ->filter(fn (Getter $getter) => $getter->authorizedToSee($getterRequest)) - ->unique(fn (Getter $getter) => $getter->uriKey()) - ->count(); - } - - protected function getIndexOperationDetails(Repository $repository, string $repositoryClass): array - { - if (! method_exists($repository, 'mcpAllowsIndex') || ! $repository->mcpAllowsIndex()) { - throw new \InvalidArgumentException("Repository '{$repository::uriKey()}' does not allow index operation"); - } - - $schema = new JsonSchemaTypeFactory; - - if (! method_exists($repositoryClass, 'indexToolSchema')) { - throw new \InvalidArgumentException("Repository '{$repository::uriKey()}' does not support index operation"); - } return [ - 'operation' => "{$repository::uriKey()}-index-tool", - 'type' => 'index', - 'title' => "{$repositoryClass::label()} Index", - 'description' => $repositoryClass::description(app(McpIndexRequest::class)), - 'schema' => $repositoryClass::indexToolSchema($schema), + 'repository' => $repositoryKey, + 'label' => $firstTool['title'], + 'description' => $firstTool['description'], + 'operations' => $operations, + 'actions' => $actions, + 'getters' => $getters, ]; } - protected function getShowOperationDetails(Repository $repository, string $repositoryClass): array + /** + * Get detailed information about a specific operation. + */ + public function getOperationDetails(string $repositoryKey, string $operationType, ?string $operationName = null): array { - if (! method_exists($repository, 'mcpAllowsShow') || ! $repository->mcpAllowsShow()) { - throw new \InvalidArgumentException("Repository '{$repository::uriKey()}' does not allow show operation"); - } - - $schema = new JsonSchemaTypeFactory; - - if (! method_exists($repositoryClass, 'showToolSchema')) { - throw new \InvalidArgumentException("Repository '{$repository::uriKey()}' does not support show operation"); + // Check if repository has MCP tools + if (McpTools::repository($repositoryKey)->isEmpty()) { + throw new \InvalidArgumentException("Repository '{$repositoryKey}' does not have MCP tools enabled. Add the HasMcpTools trait to enable MCP support."); } - return [ - 'operation' => "{$repository::uriKey()}-show-tool", - 'type' => 'show', - 'title' => "{$repositoryClass::label()} Show", - 'description' => "Show a specific {$repositoryClass::label()} record", - 'schema' => $repositoryClass::showToolSchema($schema), - ]; - } + // Find the tool from McpTools + $tool = McpTools::repository($repositoryKey) + ->where('type', $operationType) + ->when($operationName && in_array($operationType, ['action', 'getter']), function ($collection) use ($operationName) { + return $collection->filter(function ($tool) use ($operationName) { + if ($tool['type'] === 'action') { + return $tool['action']->uriKey() === $operationName; + } + if ($tool['type'] === 'getter') { + return $tool['getter']->uriKey() === $operationName; + } - protected function getStoreOperationDetails(Repository $repository, string $repositoryClass): array - { - if (! method_exists($repository, 'mcpAllowsStore') || ! $repository->mcpAllowsStore()) { - throw new \InvalidArgumentException("Repository '{$repository::uriKey()}' does not allow store operation"); - } + return false; + }); + }) + ->first(); - $schema = new JsonSchemaTypeFactory; - - if (! method_exists($repositoryClass, 'storeToolSchema')) { - throw new \InvalidArgumentException("Repository '{$repository::uriKey()}' does not support store operation"); + if (! $tool) { + throw new \InvalidArgumentException("Operation '{$operationType}' not found for repository '{$repositoryKey}'"); } - return [ - 'operation' => "{$repository::uriKey()}-store-tool", - 'type' => 'store', - 'title' => "{$repositoryClass::label()} Create", - 'description' => "Create a new {$repositoryClass::label()} record", - 'schema' => $repositoryClass::storeToolSchema($schema), - ]; - } - - protected function getUpdateOperationDetails(Repository $repository, string $repositoryClass): array - { - if (! method_exists($repository, 'mcpAllowsUpdate') || ! $repository->mcpAllowsUpdate()) { - throw new \InvalidArgumentException("Repository '{$repository::uriKey()}' does not allow update operation"); + // Check permission + if (! McpTools::canUse($tool['instance'])) { + throw new \Illuminate\Auth\Access\AuthorizationException('Not authorized to access this operation'); } + // Get schema from the tool instance $schema = new JsonSchemaTypeFactory; - - if (! method_exists($repositoryClass, 'updateToolSchema')) { - throw new \InvalidArgumentException("Repository '{$repository::uriKey()}' does not support update operation"); - } + $toolSchema = $tool['instance']->schema($schema); return [ - 'operation' => "{$repository::uriKey()}-update-tool", - 'type' => 'update', - 'title' => "{$repositoryClass::label()} Update", - 'description' => "Update an existing {$repositoryClass::label()} record", - 'schema' => $repositoryClass::updateToolSchema($schema), + 'operation' => $tool['name'], + 'type' => $tool['type'], + 'title' => $tool['title'], + 'description' => $tool['description'], + 'schema' => $toolSchema, ]; } - protected function getDeleteOperationDetails(Repository $repository, string $repositoryClass): array + /** + * Execute an operation with the provided parameters. + */ + public function executeOperation(string $repositoryKey, string $operationType, ?string $operationName, array $parameters): Response { - if (! method_exists($repository, 'mcpAllowsDelete') || ! $repository->mcpAllowsDelete()) { - throw new \InvalidArgumentException("Repository '{$repository::uriKey()}' does not allow delete operation"); - } - - $schema = new JsonSchemaTypeFactory; - - if (! method_exists($repositoryClass, 'destroyToolSchema')) { - throw new \InvalidArgumentException("Repository '{$repository::uriKey()}' does not support delete operation"); + // Check if repository has MCP tools + if (McpTools::repository($repositoryKey)->isEmpty()) { + throw new \InvalidArgumentException("Repository '{$repositoryKey}' does not have MCP tools enabled. Add the HasMcpTools trait to enable MCP support."); } - return [ - 'operation' => "{$repository::uriKey()}-delete-tool", - 'type' => 'delete', - 'title' => "{$repositoryClass::label()} Delete", - 'description' => "Delete a {$repositoryClass::label()} record", - 'schema' => $repositoryClass::destroyToolSchema($schema), - ]; - } - - protected function getProfileOperationDetails(Repository $repository, string $repositoryClass): array - { - $schema = new JsonSchemaTypeFactory; + // Find the tool from McpTools + $tool = McpTools::repository($repositoryKey) + ->where('type', $operationType) + ->when($operationName && in_array($operationType, ['action', 'getter']), function ($collection) use ($operationName) { + return $collection->filter(function ($tool) use ($operationName) { + if ($tool['type'] === 'action') { + return $tool['action']->uriKey() === $operationName; + } + if ($tool['type'] === 'getter') { + return $tool['getter']->uriKey() === $operationName; + } - return [ - 'operation' => 'profile-tool', - 'type' => 'profile', - 'title' => 'Profile', - 'description' => 'Get the authenticated user profile', - 'schema' => [ - 'include' => $schema->string()->description('Comma-separated list of relationships to include'), - ], - ]; - } + return false; + }); + }) + ->first(); - protected function getActionOperationDetails(Repository $repository, string $repositoryClass, ?string $actionName): array - { - if (! $actionName) { - throw new \InvalidArgumentException('Action name is required for action operation type'); + if (! $tool) { + throw new \InvalidArgumentException("Operation '{$operationType}' not found for repository '{$repositoryKey}'"); } - $actionRequest = app(McpActionRequest::class); - $action = $repository->resolveActions($actionRequest) - ->filter(fn ($a) => $a instanceof Action) - ->firstWhere(fn (Action $a) => $a->uriKey() === $actionName); - - if (! $action) { - throw new \InvalidArgumentException("Action '{$actionName}' not found in repository '{$repository::uriKey()}'"); + // Check permission before executing + if (! McpTools::canUse($tool['instance'])) { + throw new \Illuminate\Auth\Access\AuthorizationException('Not authorized to execute this operation'); } - $schema = new JsonSchemaTypeFactory; - - return [ - 'operation' => "{$repository::uriKey()}-{$action->uriKey()}-action-tool", - 'type' => 'action', - 'title' => $action->name(), - 'description' => $action->description($actionRequest) ?? "Execute {$action->name()} action", - 'schema' => $repositoryClass::actionToolSchema($action, $schema, $actionRequest), - ]; + // Execute based on operation type + return match ($operationType) { + 'index' => $this->executeIndexOperation($tool, $parameters), + 'show' => $this->executeShowOperation($tool, $parameters), + 'store' => $this->executeStoreOperation($tool, $parameters), + 'update' => $this->executeUpdateOperation($tool, $parameters), + 'delete' => $this->executeDeleteOperation($tool, $parameters), + 'profile' => $this->executeProfileOperation($tool, $parameters), + 'action' => $this->executeActionOperation($tool, $parameters), + 'getter' => $this->executeGetterOperation($tool, $parameters), + default => throw new \InvalidArgumentException("Invalid operation type: {$operationType}"), + }; } - protected function getGetterOperationDetails(Repository $repository, string $repositoryClass, ?string $getterName): array - { - if (! $getterName) { - throw new \InvalidArgumentException('Getter name is required for getter operation type'); - } - - $getterRequest = app(McpGetterRequest::class); - $getter = $repository->resolveGetters($getterRequest) - ->filter(fn ($g) => $g instanceof Getter) - ->firstWhere(fn (Getter $g) => $g->uriKey() === $getterName); - - if (! $getter) { - throw new \InvalidArgumentException("Getter '{$getterName}' not found in repository '{$repository::uriKey()}'"); - } - - $schema = new JsonSchemaTypeFactory; - return [ - 'operation' => "{$repository::uriKey()}-{$getter->uriKey()}-getter-tool", - 'type' => 'getter', - 'title' => $getter->name(), - 'description' => $getter->description($getterRequest) ?? "Execute {$getter->name()} getter", - 'schema' => $repositoryClass::getterToolSchema($getter, $schema, $getterRequest), - ]; - } - - protected function executeIndexOperation(Repository $repository, array $parameters): Response + protected function executeIndexOperation(array $tool, array $parameters): Response { - if (! method_exists($repository, 'mcpAllowsIndex') || ! $repository->mcpAllowsIndex()) { - throw new \InvalidArgumentException("Repository '{$repository::uriKey()}' does not allow index operation"); - } - - $request = app(McpIndexRequest::class); - $request->replace($parameters); - - $result = $repository->indexTool($request); + $request = new Request($parameters); - return Response::json($result); + return $tool['instance']->handle($request); } - protected function executeShowOperation(Repository $repository, array $parameters): Response + protected function executeShowOperation(array $tool, array $parameters): Response { - if (! method_exists($repository, 'mcpAllowsShow') || ! $repository->mcpAllowsShow()) { - throw new \InvalidArgumentException("Repository '{$repository::uriKey()}' does not allow show operation"); - } - - $request = app(McpShowRequest::class); - $request->replace($parameters); - - if ($id = $request->input('id')) { - $request->setRouteResolver(function () use ($id) { - return new class($id) - { - public function __construct(private $id) {} - - public function parameter($key, $default = null) - { - return $key === 'repositoryId' ? $this->id : $default; - } - }; - }); - } - - $result = $repository->showTool($request); + $request = new Request($parameters); - return Response::json($result); + return $tool['instance']->handle($request); } - protected function executeStoreOperation(Repository $repository, array $parameters): Response + protected function executeStoreOperation(array $tool, array $parameters): Response { - if (! method_exists($repository, 'mcpAllowsStore') || ! $repository->mcpAllowsStore()) { - throw new \InvalidArgumentException("Repository '{$repository::uriKey()}' does not allow store operation"); - } - - $request = app(McpStoreRequest::class); - $request->replace($parameters); + $request = new Request($parameters); - $result = $repository->storeTool($request); - - return Response::json($result); + return $tool['instance']->handle($request); } - protected function executeUpdateOperation(Repository $repository, array $parameters): Response + protected function executeUpdateOperation(array $tool, array $parameters): Response { - if (! method_exists($repository, 'mcpAllowsUpdate') || ! $repository->mcpAllowsUpdate()) { - throw new \InvalidArgumentException("Repository '{$repository::uriKey()}' does not allow update operation"); - } + $request = new Request($parameters); - $request = app(McpUpdateRequest::class); - $request->replace($parameters); - - if ($id = $request->input('id')) { - $request->setRouteResolver(function () use ($id) { - return new class($id) - { - public function __construct(private $id) {} - - public function parameter($key, $default = null) - { - return $key === 'repositoryId' ? $this->id : $default; - } - }; - }); - } - - $result = $repository->updateTool($request); - - return Response::json($result); + return $tool['instance']->handle($request); } - protected function executeDeleteOperation(Repository $repository, array $parameters): Response + protected function executeDeleteOperation(array $tool, array $parameters): Response { - if (! method_exists($repository, 'mcpAllowsDelete') || ! $repository->mcpAllowsDelete()) { - throw new \InvalidArgumentException("Repository '{$repository::uriKey()}' does not allow delete operation"); - } - - $request = app(McpDestroyRequest::class); - $request->replace($parameters); - - if ($id = $request->input('id')) { - $request->setRouteResolver(function () use ($id) { - return new class($id) - { - public function __construct(private $id) {} + $request = new Request($parameters); - public function parameter($key, $default = null) - { - return $key === 'repositoryId' ? $this->id : $default; - } - }; - }); - } - - $result = $repository->deleteTool($request); - - return Response::json($result); + return $tool['instance']->handle($request); } - protected function executeProfileOperation(Repository $repository, array $parameters): Response + protected function executeProfileOperation(array $tool, array $parameters): Response { - $tool = new ProfileTool(get_class($repository)); - $request = app(McpRequest::class); - $request->replace($parameters); + $request = new Request($parameters); - return $tool->handle($request); + return $tool['instance']->handle($request); } - protected function executeActionOperation(Repository $repository, string $repositoryClass, ?string $actionName, array $parameters): Response + protected function executeActionOperation(array $tool, array $parameters): Response { - if (! $actionName) { - throw new \InvalidArgumentException('Action name is required for action operation type'); - } - - $actionRequest = app(McpActionRequest::class); - $action = $repository->resolveActions($actionRequest) - ->filter(fn ($a) => $a instanceof Action) - ->firstWhere(fn (Action $a) => $a->uriKey() === $actionName); + $request = new Request($parameters); - if (! $action) { - throw new \InvalidArgumentException("Action '{$actionName}' not found in repository '{$repository::uriKey()}'"); - } - - $actionRequest->replace($parameters); - $actionRequest->merge([ - 'mcp_repository_key' => $repository->uriKey(), - ]); - - if ($actionRequest->has('repositories') && is_string($actionRequest->input('repositories'))) { - $repositories = json_decode($actionRequest->input('repositories'), true) ?? []; - $actionRequest->merge(['repositories' => $repositories]); - } - - if ($id = $actionRequest->input('id')) { - $actionRequest->setRouteResolver(function () use ($id) { - return new class($id) - { - public function __construct(private $id) {} - - public function parameter($key, $default = null) - { - return $key === 'repositoryId' ? $this->id : $default; - } - }; - }); - } - - $result = $repository->actionTool($action, $actionRequest); - - return Response::json($result); + return $tool['instance']->handle($request); } - protected function executeGetterOperation(Repository $repository, string $repositoryClass, ?string $getterName, array $parameters): Response + protected function executeGetterOperation(array $tool, array $parameters): Response { - if (! $getterName) { - throw new \InvalidArgumentException('Getter name is required for getter operation type'); - } - - $getterRequest = app(McpGetterRequest::class); - $getter = $repository->resolveGetters($getterRequest) - ->filter(fn ($g) => $g instanceof Getter) - ->firstWhere(fn (Getter $g) => $g->uriKey() === $getterName); - - if (! $getter) { - throw new \InvalidArgumentException("Getter '{$getterName}' not found in repository '{$repository::uriKey()}'"); - } + $request = new Request($parameters); - $getterRequest->replace($parameters); - $getterRequest->merge([ - 'mcp_repository_key' => $repository->uriKey(), - ]); - - if ($getterRequest->has('repositories') && is_string($getterRequest->input('repositories'))) { - $repositories = json_decode($getterRequest->input('repositories'), true) ?? []; - $getterRequest->merge(['repositories' => $repositories]); - } - - if ($id = $getterRequest->input('id')) { - $getterRequest->setRouteResolver(function () use ($id) { - return new class($id) - { - public function __construct(private $id) {} - - public function parameter($key, $default = null) - { - return $key === 'repositoryId' ? $this->id : $default; - } - }; - }); - } - - $result = $repository->getterTool($getter, $getterRequest); - - return Response::json($result); - } - - /** - * Clear the registry cache. - */ - public function clearCache(): void - { - Cache::forget($this->cacheKey); + return $tool['instance']->handle($request); } } diff --git a/src/RestifyApplicationServiceProvider.php b/src/RestifyApplicationServiceProvider.php index 2d6f9a58..fb5260e3 100644 --- a/src/RestifyApplicationServiceProvider.php +++ b/src/RestifyApplicationServiceProvider.php @@ -11,6 +11,8 @@ use Binaryk\LaravelRestify\Http\Controllers\Auth\ResetPasswordController; use Binaryk\LaravelRestify\Http\Controllers\Auth\VerifyController; use Binaryk\LaravelRestify\Http\Middleware\RestifyInjector; +use Binaryk\LaravelRestify\MCP\Bootstrap\BootMcpTools; +use Binaryk\LaravelRestify\MCP\McpToolsManager; use Illuminate\Contracts\Http\Kernel; use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\Gate; @@ -142,5 +144,10 @@ protected function singleton(): void if (! App::runningUnitTests()) { $this->app->singletonIf(RelatedDto::class, fn ($app) => new RelatedDto); } + + // Register MCP tools manager as singleton + $this->app->singleton(McpToolsManager::class, function ($app) { + return new McpToolsManager($app->make(BootMcpTools::class)); + }); } } diff --git a/tests/MCP/WrapperToolsIntegrationTest.php b/tests/MCP/WrapperToolsIntegrationTest.php index cd048af1..312b3c58 100644 --- a/tests/MCP/WrapperToolsIntegrationTest.php +++ b/tests/MCP/WrapperToolsIntegrationTest.php @@ -634,9 +634,9 @@ public function mcpAllowsStore(): bool $resultContent = json_decode($response->json()['result']['content'][0]['text'], true); - // Should return error about not allowing store + // Should return error about operation not found (because it's not discovered when mcpAllowsStore is false) $this->assertArrayHasKey('error', $resultContent); - $this->assertStringContainsString('does not allow store operation', $resultContent['error']); + $this->assertStringContainsString("Operation 'store' not found", $resultContent['error']); } public function test_wrapper_mode_complete_workflow(): void From c043d89d3b9707dcf3de1eeae2e3ca6f133a5f62 Mon Sep 17 00:00:00 2001 From: binaryk Date: Sun, 26 Oct 2025 20:17:59 +0000 Subject: [PATCH 2/7] Fix styling --- src/MCP/RestifyServer.php | 14 -------------- src/MCP/Services/ToolRegistry.php | 12 ------------ 2 files changed, 26 deletions(-) diff --git a/src/MCP/RestifyServer.php b/src/MCP/RestifyServer.php index c00f1165..5672e752 100644 --- a/src/MCP/RestifyServer.php +++ b/src/MCP/RestifyServer.php @@ -77,20 +77,7 @@ */ use Binaryk\LaravelRestify\Actions\Action; -use Binaryk\LaravelRestify\Getters\Getter; -use Binaryk\LaravelRestify\MCP\Bootstrap\BootMcpTools; -use Binaryk\LaravelRestify\MCP\Concerns\HasMcpTools; -use Binaryk\LaravelRestify\MCP\Requests\McpActionRequest; -use Binaryk\LaravelRestify\MCP\Requests\McpGetterRequest; use Binaryk\LaravelRestify\MCP\Resources\ApplicationInfo; -use Binaryk\LaravelRestify\MCP\Tools\Operations\ActionTool; -use Binaryk\LaravelRestify\MCP\Tools\Operations\DeleteTool; -use Binaryk\LaravelRestify\MCP\Tools\Operations\GetterTool; -use Binaryk\LaravelRestify\MCP\Tools\Operations\IndexTool; -use Binaryk\LaravelRestify\MCP\Tools\Operations\ProfileTool; -use Binaryk\LaravelRestify\MCP\Tools\Operations\ShowTool; -use Binaryk\LaravelRestify\MCP\Tools\Operations\StoreTool; -use Binaryk\LaravelRestify\MCP\Tools\Operations\UpdateTool; use Binaryk\LaravelRestify\MCP\Tools\Wrapper\DiscoverRepositoriesTool; use Binaryk\LaravelRestify\MCP\Tools\Wrapper\ExecuteOperationTool; use Binaryk\LaravelRestify\MCP\Tools\Wrapper\GetOperationDetailsTool; @@ -317,5 +304,4 @@ public function getAuthorizedTools(): \Illuminate\Support\Collection 'type' => $tool['type'], ]); } - } diff --git a/src/MCP/Services/ToolRegistry.php b/src/MCP/Services/ToolRegistry.php index b99c0651..5403c2da 100644 --- a/src/MCP/Services/ToolRegistry.php +++ b/src/MCP/Services/ToolRegistry.php @@ -2,19 +2,8 @@ namespace Binaryk\LaravelRestify\MCP\Services; -use Binaryk\LaravelRestify\Actions\Action; -use Binaryk\LaravelRestify\Getters\Getter; use Binaryk\LaravelRestify\MCP\McpTools; -use Binaryk\LaravelRestify\MCP\Requests\McpActionRequest; -use Binaryk\LaravelRestify\MCP\Requests\McpDestroyRequest; -use Binaryk\LaravelRestify\MCP\Requests\McpGetterRequest; -use Binaryk\LaravelRestify\MCP\Requests\McpIndexRequest; -use Binaryk\LaravelRestify\MCP\Requests\McpRequest; -use Binaryk\LaravelRestify\MCP\Requests\McpShowRequest; -use Binaryk\LaravelRestify\MCP\Requests\McpStoreRequest; -use Binaryk\LaravelRestify\MCP\Requests\McpUpdateRequest; use Binaryk\LaravelRestify\MCP\RestifyServer; -use Binaryk\LaravelRestify\MCP\Tools\Operations\ProfileTool; use Binaryk\LaravelRestify\Repositories\Repository; use Illuminate\JsonSchema\JsonSchemaTypeFactory; use Illuminate\Support\Collection; @@ -238,7 +227,6 @@ public function executeOperation(string $repositoryKey, string $operationType, ? }; } - protected function executeIndexOperation(array $tool, array $parameters): Response { $request = new Request($parameters); From 14b984197a4766775a42acfbb2988071d18d213e Mon Sep 17 00:00:00 2001 From: Eduard Lupacescu Date: Mon, 27 Oct 2025 11:20:39 +0200 Subject: [PATCH 3/7] fix: refactoring --- src/MCP/Bootstrap/BootMcpTools.php | 161 ++++----- src/MCP/Collections/ToolsCollection.php | 38 +++ src/MCP/Enums/OperationTypeEnum.php | 50 +++ src/MCP/Enums/ToolsCategoryEnum.php | 46 +++ src/MCP/McpTools.php | 4 + src/MCP/McpToolsManager.php | 308 +++++++++++++++++- src/MCP/RestifyServer.php | 102 +++--- src/MCP/Services/ToolRegistry.php | 297 ----------------- .../Wrapper/DiscoverRepositoriesTool.php | 5 +- .../Tools/Wrapper/ExecuteOperationTool.php | 6 +- .../Tools/Wrapper/GetOperationDetailsTool.php | 6 +- .../Wrapper/GetRepositoryOperationsTool.php | 5 +- 12 files changed, 551 insertions(+), 477 deletions(-) create mode 100644 src/MCP/Collections/ToolsCollection.php create mode 100644 src/MCP/Enums/OperationTypeEnum.php create mode 100644 src/MCP/Enums/ToolsCategoryEnum.php delete mode 100644 src/MCP/Services/ToolRegistry.php diff --git a/src/MCP/Bootstrap/BootMcpTools.php b/src/MCP/Bootstrap/BootMcpTools.php index 84bfe37d..9c2a6297 100644 --- a/src/MCP/Bootstrap/BootMcpTools.php +++ b/src/MCP/Bootstrap/BootMcpTools.php @@ -4,7 +4,10 @@ use Binaryk\LaravelRestify\Actions\Action; use Binaryk\LaravelRestify\Getters\Getter; +use Binaryk\LaravelRestify\MCP\Collections\ToolsCollection; use Binaryk\LaravelRestify\MCP\Concerns\HasMcpTools; +use Binaryk\LaravelRestify\MCP\Enums\OperationTypeEnum; +use Binaryk\LaravelRestify\MCP\Enums\ToolsCategoryEnum; use Binaryk\LaravelRestify\MCP\Requests\McpActionRequest; use Binaryk\LaravelRestify\MCP\Requests\McpGetterRequest; use Binaryk\LaravelRestify\MCP\Tools\Operations\ActionTool; @@ -18,6 +21,7 @@ use Binaryk\LaravelRestify\Repositories\Repository; use Binaryk\LaravelRestify\Restify; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\Cache; /** @@ -44,6 +48,14 @@ public function boot(): array $mode = config('restify.mcp.mode', 'direct'); $cacheKey = "restify.mcp.all_tools_metadata.{$mode}"; + if (App::hasDebugModeEnabled()) { + return collect() + ->merge($this->discoverCustomTools()) + ->merge($this->discoverRepositoryTools()) + ->values() + ->toArray(); + } + $tools = Cache::remember($cacheKey, 3600, function (): array { return collect() ->merge($this->discoverCustomTools()) @@ -71,13 +83,13 @@ protected function discoverCustomTools(): Collection if (class_exists($fqdn) && ! in_array($fqdn, $excludedTools, true)) { $instance = app($fqdn); $tools->push([ - 'type' => 'custom', + 'type' => OperationTypeEnum::custom, 'name' => $instance->name(), 'title' => $instance->title(), 'description' => $instance->description(), 'class' => $fqdn, 'instance' => $instance, - 'category' => 'Custom Tools', + 'category' => ToolsCategoryEnum::CUSTOM_TOOLS->value, ]); } } @@ -93,13 +105,13 @@ protected function discoverCustomTools(): Collection if (class_exists($fqdn) && ! in_array($fqdn, $excludedTools, true)) { $instance = app($fqdn); $tools->push([ - 'type' => 'wrapper', + 'type' => OperationTypeEnum::wrapper, 'name' => $instance->name(), 'title' => $instance->title(), 'description' => $instance->description(), 'class' => $fqdn, 'instance' => $instance, - 'category' => 'Wrapper Tools', + 'category' => ToolsCategoryEnum::WRAPPER_TOOLS->value, ]); } } @@ -116,13 +128,13 @@ protected function discoverCustomTools(): Collection if (class_exists($fqdn) && ! in_array($fqdn, $excludedTools, true)) { $instance = app($fqdn); $tools->push([ - 'type' => 'custom', + 'type' => OperationTypeEnum::custom, 'name' => $instance->name(), 'title' => $instance->title(), 'description' => $instance->description(), 'class' => $fqdn, 'instance' => $instance, - 'category' => 'Custom Tools', + 'category' => ToolsCategoryEnum::CUSTOM_TOOLS->value, ]); } } @@ -135,13 +147,13 @@ protected function discoverCustomTools(): Collection if (class_exists($toolClass)) { $instance = app($toolClass); $tools->push([ - 'type' => 'custom', + 'type' => OperationTypeEnum::custom, 'name' => $instance->name(), 'title' => $instance->title(), 'description' => $instance->description(), 'class' => $toolClass, 'instance' => $instance, - 'category' => 'Custom Tools', + 'category' => ToolsCategoryEnum::CUSTOM_TOOLS->value, ]); } } @@ -155,115 +167,65 @@ protected function discoverCustomTools(): Collection protected function discoverRepositoryTools(): Collection { return collect(Restify::$repositories) - ->filter(fn (string $repo): bool => in_array(HasMcpTools::class, class_uses_recursive($repo))) - ->flatMap(fn (string $repoClass): Collection => $this->discoverRepositoryOperations($repoClass)) + ->filter(fn(string $repo): bool => in_array(HasMcpTools::class, class_uses_recursive($repo))) + ->flatMap(fn(string $repoClass): Collection => $this->discoverRepositoryOperations($repoClass)) ->values(); } /** * Discover all operations (CRUD, actions, getters) for a specific repository. */ - protected function discoverRepositoryOperations(string $repositoryClass): Collection + protected function discoverRepositoryOperations(string $repositoryClass): ToolsCollection { $repository = app($repositoryClass); - $tools = collect(); + $tools = ToolsCollection::make(); - // Profile tool (only for users repository) if ($repository::uriKey() === 'users') { - $instance = new ProfileTool($repositoryClass); - $tools->push([ - 'type' => 'profile', - 'name' => $instance->name(), - 'title' => $instance->title(), - 'description' => $instance->description(), - 'class' => ProfileTool::class, - 'instance' => $instance, - 'repository' => $repository::uriKey(), - 'category' => 'Profile', - ]); + $tools->pushTool( + new ProfileTool($repositoryClass), + $repository::uriKey() + ); } - // Index operation if (method_exists($repository, 'mcpAllowsIndex') && $repository->mcpAllowsIndex()) { - $instance = new IndexTool($repositoryClass); - $tools->push([ - 'type' => 'index', - 'name' => $instance->name(), - 'title' => $instance->title(), - 'description' => $instance->description(), - 'class' => IndexTool::class, - 'instance' => $instance, - 'repository' => $repository::uriKey(), - 'category' => 'CRUD Operations', - ]); + $tools->pushTool( + new IndexTool($repositoryClass), + $repository::uriKey() + ); } - // Show operation if (method_exists($repository, 'mcpAllowsShow') && $repository->mcpAllowsShow()) { - $instance = new ShowTool($repositoryClass); - $tools->push([ - 'type' => 'show', - 'name' => $instance->name(), - 'title' => $instance->title(), - 'description' => $instance->description(), - 'class' => ShowTool::class, - 'instance' => $instance, - 'repository' => $repository::uriKey(), - 'category' => 'CRUD Operations', - ]); + $tools->pushTool( + new ShowTool($repositoryClass), + $repository::uriKey() + ); } - // Store operation if (method_exists($repository, 'mcpAllowsStore') && $repository->mcpAllowsStore()) { - $instance = new StoreTool($repositoryClass); - $tools->push([ - 'type' => 'store', - 'name' => $instance->name(), - 'title' => $instance->title(), - 'description' => $instance->description(), - 'class' => StoreTool::class, - 'instance' => $instance, - 'repository' => $repository::uriKey(), - 'category' => 'CRUD Operations', - ]); + $tools->pushTool( + new StoreTool($repositoryClass), + $repository::uriKey() + ); } - // Update operation if (method_exists($repository, 'mcpAllowsUpdate') && $repository->mcpAllowsUpdate()) { - $instance = new UpdateTool($repositoryClass); - $tools->push([ - 'type' => 'update', - 'name' => $instance->name(), - 'title' => $instance->title(), - 'description' => $instance->description(), - 'class' => UpdateTool::class, - 'instance' => $instance, - 'repository' => $repository::uriKey(), - 'category' => 'CRUD Operations', - ]); + $tools->pushTool( + new UpdateTool($repositoryClass), + $repository::uriKey() + ); } - // Delete operation if (method_exists($repository, 'mcpAllowsDelete') && $repository->mcpAllowsDelete()) { - $instance = new DeleteTool($repositoryClass); - $tools->push([ - 'type' => 'delete', - 'name' => $instance->name(), - 'title' => $instance->title(), - 'description' => $instance->description(), - 'class' => DeleteTool::class, - 'instance' => $instance, - 'repository' => $repository::uriKey(), - 'category' => 'CRUD Operations', - ]); + $tools->pushTool( + new DeleteTool($repositoryClass), + $repository::uriKey() + ); } - // Actions if (method_exists($repository, 'mcpAllowsActions') && $repository->mcpAllowsActions()) { $tools = $tools->merge($this->discoverActions($repositoryClass, $repository)); } - // Getters if (method_exists($repository, 'mcpAllowsGetters') && $repository->mcpAllowsGetters()) { $tools = $tools->merge($this->discoverGetters($repositoryClass, $repository)); } @@ -271,30 +233,27 @@ protected function discoverRepositoryOperations(string $repositoryClass): Collec return $tools; } - /** - * Discover all actions for a repository. - */ protected function discoverActions(string $repositoryClass, Repository $repository): Collection { $actionRequest = app(McpActionRequest::class); return $repository->resolveActions($actionRequest) - ->filter(fn ($action): bool => $action instanceof Action) - ->filter(fn (Action $action): bool => $action->isShownOnMcp($actionRequest, $repository)) - ->filter(fn (Action $action): bool => $action->authorizedToSee($actionRequest)) - ->unique(fn (Action $action): string => $action->uriKey()) + ->filter(fn($action): bool => $action instanceof Action) + ->filter(fn(Action $action): bool => $action->isShownOnMcp($actionRequest, $repository)) + ->filter(fn(Action $action): bool => $action->authorizedToSee($actionRequest)) + ->unique(fn(Action $action): string => $action->uriKey()) ->map(function (Action $action) use ($repositoryClass, $repository): array { $instance = new ActionTool($repositoryClass, $action); return [ - 'type' => 'action', + 'type' => OperationTypeEnum::action, 'name' => $instance->name(), 'title' => $instance->title(), 'description' => $instance->description(), 'class' => ActionTool::class, 'instance' => $instance, 'repository' => $repository::uriKey(), - 'category' => 'Actions', + 'category' => ToolsCategoryEnum::ACTIONS->value, 'action' => $action, ]; }) @@ -309,22 +268,22 @@ protected function discoverGetters(string $repositoryClass, Repository $reposito $getterRequest = app(McpGetterRequest::class); return $repository->resolveGetters($getterRequest) - ->filter(fn ($getter): bool => $getter instanceof Getter) - ->filter(fn (Getter $getter): bool => $getter->isShownOnMcp($getterRequest, $repository)) - ->filter(fn (Getter $getter): bool => $getter->authorizedToSee($getterRequest)) - ->unique(fn (Getter $getter): string => $getter->uriKey()) + ->filter(fn($getter): bool => $getter instanceof Getter) + ->filter(fn(Getter $getter): bool => $getter->isShownOnMcp($getterRequest, $repository)) + ->filter(fn(Getter $getter): bool => $getter->authorizedToSee($getterRequest)) + ->unique(fn(Getter $getter): string => $getter->uriKey()) ->map(function (Getter $getter) use ($repositoryClass, $repository): array { $instance = new GetterTool($repositoryClass, $getter); return [ - 'type' => 'getter', + 'type' => OperationTypeEnum::getter, 'name' => $instance->name(), 'title' => $instance->title(), 'description' => $instance->description(), 'class' => GetterTool::class, 'instance' => $instance, 'repository' => $repository::uriKey(), - 'category' => 'Getters', + 'category' => ToolsCategoryEnum::GETTERS->value, 'getter' => $getter, ]; }) diff --git a/src/MCP/Collections/ToolsCollection.php b/src/MCP/Collections/ToolsCollection.php new file mode 100644 index 00000000..ec94bb2d --- /dev/null +++ b/src/MCP/Collections/ToolsCollection.php @@ -0,0 +1,38 @@ +map(fn (array $tool): array => [ + 'name' => $tool['name'], + 'title' => $tool['title'], + 'description' => $tool['description'], + 'category' => $tool['category'], + 'type' => $tool['type'], + ]); + } + + public function pushTool(Tool $tool, string $repositoryKey, array $extra = []): self + { + return $this->push(array_merge([ + 'type' => OperationTypeEnum::fromTool($tool), + 'name' => $tool->name(), + 'title' => $tool->title(), + 'description' => $tool->description(), + 'class' => get_class($tool), + 'instance' => $tool, + 'repository' => $repositoryKey, + 'category' => ToolsCategoryEnum::fromTool($tool)->value, + ], $extra)); + } + +} diff --git a/src/MCP/Enums/OperationTypeEnum.php b/src/MCP/Enums/OperationTypeEnum.php new file mode 100644 index 00000000..6bc25214 --- /dev/null +++ b/src/MCP/Enums/OperationTypeEnum.php @@ -0,0 +1,50 @@ + self::index, + $tool instanceof ShowTool => self::show, + $tool instanceof StoreTool => self::store, + $tool instanceof UpdateTool => self::update, + $tool instanceof DeleteTool => self::delete, + $tool instanceof ProfileTool => self::profile, + $tool instanceof ActionTool => self::action, + $tool instanceof GetterTool => self::getter, + $tool instanceof GetOperationDetailsTool, + $tool instanceof ExecuteOperationTool, + $tool instanceof DiscoverRepositoriesTool, + $tool instanceof GetRepositoryOperationsTool => self::wrapper, + default => self::custom, + }; + } +} diff --git a/src/MCP/Enums/ToolsCategoryEnum.php b/src/MCP/Enums/ToolsCategoryEnum.php new file mode 100644 index 00000000..5b8d9829 --- /dev/null +++ b/src/MCP/Enums/ToolsCategoryEnum.php @@ -0,0 +1,46 @@ + self::CRUD_OPERATIONS, + $tool instanceof ProfileTool => self::PROFILE, + $tool instanceof ActionTool => self::ACTIONS, + $tool instanceof GetterTool => self::GETTERS, + $tool instanceof GetOperationDetailsTool, + $tool instanceof ExecuteOperationTool, + $tool instanceof DiscoverRepositoriesTool, + $tool instanceof GetRepositoryOperationsTool => self::WRAPPER_TOOLS, + default => self::CUSTOM_TOOLS, + }; + } +} diff --git a/src/MCP/McpTools.php b/src/MCP/McpTools.php index 8910e30f..cab891dd 100644 --- a/src/MCP/McpTools.php +++ b/src/MCP/McpTools.php @@ -20,6 +20,10 @@ * @method static \Illuminate\Support\Collection byCategory() * @method static \Illuminate\Support\Collection byRepository() * @method static void rediscover() + * @method static \Illuminate\Support\Collection getAvailableRepositories(?string $search = null) + * @method static array getRepositoryOperations(string $repositoryKey) + * @method static array getOperationDetails(string $repositoryKey, string $operationType, ?string $operationName = null) + * @method static \Laravel\Mcp\Response executeOperation(string $repositoryKey, string $operationType, ?string $operationName, array $parameters) * * @see \Binaryk\LaravelRestify\MCP\McpToolsManager */ diff --git a/src/MCP/McpToolsManager.php b/src/MCP/McpToolsManager.php index c0b949aa..76866f12 100644 --- a/src/MCP/McpToolsManager.php +++ b/src/MCP/McpToolsManager.php @@ -3,8 +3,14 @@ namespace Binaryk\LaravelRestify\MCP; use Binaryk\LaravelRestify\MCP\Bootstrap\BootMcpTools; +use Binaryk\LaravelRestify\MCP\Collections\ToolsCollection; +use Binaryk\LaravelRestify\MCP\Enums\OperationTypeEnum; +use Binaryk\LaravelRestify\Repositories\Repository; +use Illuminate\JsonSchema\JsonSchemaTypeFactory; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache; +use Laravel\Mcp\Request; +use Laravel\Mcp\Response; /** * Manager class for MCP tools discovery and management. @@ -48,13 +54,13 @@ public function __construct( * Get all discovered tools. * Automatically discovers tools if not already done. */ - public function all(): Collection + public function all(): ToolsCollection { if (! $this->discovered) { $this->discover(); } - return collect($this->discoveredTools); + return ToolsCollection::make($this->discoveredTools); } /** @@ -82,7 +88,7 @@ public function register(array $tools): void /** * Get tools for a specific category. */ - public function category(string $category): Collection + public function category(string $category): ToolsCollection { return $this->all()->where('category', $category); } @@ -90,7 +96,7 @@ public function category(string $category): Collection /** * Get tools for a specific repository. */ - public function repository(string $repositoryKey): Collection + public function repository(string $repositoryKey): ToolsCollection { return $this->all()->where('repository', $repositoryKey); } @@ -114,7 +120,7 @@ public function canUse(string|object $tool): bool /** * Get authorized tools for current user (filtered by permissions). */ - public function authorized(): Collection + public function authorized(): ToolsCollection { return $this->all()->filter(fn (array $tool): bool => $this->canUse($tool['instance'])); } @@ -153,7 +159,7 @@ public function clear(): void /** * Get tools grouped by category. */ - public function byCategory(): Collection + public function byCategory(): ToolsCollection { return $this->all()->groupBy('category'); } @@ -161,7 +167,7 @@ public function byCategory(): Collection /** * Get tools grouped by repository. */ - public function byRepository(): Collection + public function byRepository(): ToolsCollection { return $this->all() ->filter(fn (array $tool): bool => isset($tool['repository'])) @@ -176,4 +182,292 @@ public function rediscover(): void $this->clear(); $this->discover(); } + + /** + * Get all available repositories with their MCP-enabled operations. + */ + public function getAvailableRepositories(?string $search = null): ToolsCollection + { + // Get all repository tools from authorized tools + $repositories = $this->authorized() + ->filter(fn (array $tool): bool => isset($tool['repository'])) + ->groupBy('repository') + ->map(function (Collection $tools, string $repositoryKey): array { + $firstTool = $tools->first(); + + // Count operations by type + $operations = $tools->whereIn('type', [ + OperationTypeEnum::index, + OperationTypeEnum::show, + OperationTypeEnum::store, + OperationTypeEnum::update, + OperationTypeEnum::delete, + OperationTypeEnum::profile, + ])->pluck('type')->map(fn ($type) => $type->name)->values()->toArray(); + $actionsCount = $tools->where('type', OperationTypeEnum::action)->count(); + $gettersCount = $tools->where('type', OperationTypeEnum::getter)->count(); + + // Get repository metadata from the first tool instance + /** + * @var Repository $repository + */ + $repository = app($firstTool['instance']->repository ?? Repository::class); + + return [ + 'name' => $repositoryKey, + 'label' => $firstTool['title'] ?? $repositoryKey, + 'description' => $firstTool['description'] ?? '', + 'model' => class_basename($repository::guessModelClassName()), + 'operations' => $operations, + 'actions_count' => $actionsCount, + 'getters_count' => $gettersCount, + ]; + }) + ->values(); + + if ($search) { + $search = strtolower($search); + $repositories = $repositories->filter(function ($repo) use ($search) { + return str_contains(strtolower($repo['name']), $search) || + str_contains(strtolower($repo['label']), $search) || + str_contains(strtolower($repo['description'] ?? ''), $search); + }); + } + + return $repositories->values(); + } + + /** + * Get all operations available for a specific repository. + */ + public function getRepositoryOperations(string $repositoryKey): array + { + // Get all tools for this repository + $tools = $this->repository($repositoryKey); + + if ($tools->isEmpty()) { + throw new \InvalidArgumentException("Repository '{$repositoryKey}' does not have MCP tools enabled. Add the HasMcpTools trait to enable MCP support."); + } + + // Filter by permissions + $tools = $tools->filter(fn (array $tool): bool => $this->canUse($tool['instance'])); + + if ($tools->isEmpty()) { + throw new \InvalidArgumentException("Repository '{$repositoryKey}' found, but you don't have permission to access any of its operations"); + } + + $firstTool = $tools->first(); + + // Build operations list + $operations = $tools->whereIn('type', [ + OperationTypeEnum::index, + OperationTypeEnum::show, + OperationTypeEnum::store, + OperationTypeEnum::update, + OperationTypeEnum::delete, + OperationTypeEnum::profile, + ]) + ->map(fn (array $tool): array => [ + 'type' => $tool['type']->name, + 'name' => $tool['name'], + 'title' => $tool['title'], + 'description' => $tool['description'], + ]) + ->values() + ->toArray(); + + // Build actions list + $actions = $tools->where('type', OperationTypeEnum::action) + ->map(fn (array $tool): array => [ + 'type' => OperationTypeEnum::action->name, + 'name' => $tool['action']->uriKey(), + 'tool_name' => $tool['name'], + 'title' => $tool['title'], + 'description' => $tool['description'], + ]) + ->values() + ->toArray(); + + // Build getters list + $getters = $tools->where('type', OperationTypeEnum::getter) + ->map(fn (array $tool): array => [ + 'type' => OperationTypeEnum::getter->name, + 'name' => $tool['getter']->uriKey(), + 'tool_name' => $tool['name'], + 'title' => $tool['title'], + 'description' => $tool['description'], + ]) + ->values() + ->toArray(); + + return [ + 'repository' => $repositoryKey, + 'label' => $firstTool['title'], + 'description' => $firstTool['description'], + 'operations' => $operations, + 'actions' => $actions, + 'getters' => $getters, + ]; + } + + /** + * Get detailed information about a specific operation. + */ + public function getOperationDetails(string $repositoryKey, string $operationType, ?string $operationName = null): array + { + // Convert string operation type to enum + $operationTypeEnum = OperationTypeEnum::{$operationType}; + + // Check if repository has MCP tools + if ($this->repository($repositoryKey)->isEmpty()) { + throw new \InvalidArgumentException("Repository '{$repositoryKey}' does not have MCP tools enabled. Add the HasMcpTools trait to enable MCP support."); + } + + // Find the tool + $tool = $this->repository($repositoryKey) + ->where('type', $operationTypeEnum) + ->when($operationName && in_array($operationType, [OperationTypeEnum::action, OperationTypeEnum::getter]), function ($collection) use ($operationName) { + return $collection->filter(function ($tool) use ($operationName) { + if ($tool['type'] === OperationTypeEnum::action) { + return $tool['action']->uriKey() === $operationName; + } + if ($tool['type'] === OperationTypeEnum::getter) { + return $tool['getter']->uriKey() === $operationName; + } + + return false; + }); + }) + ->first(); + + if (! $tool) { + throw new \InvalidArgumentException("Operation '{$operationType}' not found for repository '{$repositoryKey}'"); + } + + // Check permission + if (! $this->canUse($tool['instance'])) { + throw new \Illuminate\Auth\Access\AuthorizationException('Not authorized to access this operation'); + } + + // Get schema from the tool instance + $schema = new JsonSchemaTypeFactory; + $toolSchema = $tool['instance']->schema($schema); + + return [ + 'operation' => $tool['name'], + 'type' => $tool['type']->name, + 'title' => $tool['title'], + 'description' => $tool['description'], + 'schema' => $toolSchema, + ]; + } + + /** + * Execute an operation with the provided parameters. + */ + public function executeOperation(string $repositoryKey, string $operationType, ?string $operationName, array $parameters): Response + { + // Convert string operation type to enum + $operationTypeEnum = OperationTypeEnum::{$operationType}; + + // Check if repository has MCP tools + if ($this->repository($repositoryKey)->isEmpty()) { + throw new \InvalidArgumentException("Repository '{$repositoryKey}' does not have MCP tools enabled. Add the HasMcpTools trait to enable MCP support."); + } + + // Find the tool + $tool = $this->repository($repositoryKey) + ->where('type', $operationTypeEnum) + ->when($operationName && in_array($operationType, [OperationTypeEnum::action, OperationTypeEnum::getter]), function ($collection) use ($operationName) { + return $collection->filter(function ($tool) use ($operationName) { + if ($tool['type'] === OperationTypeEnum::action) { + return $tool['action']->uriKey() === $operationName; + } + if ($tool['type'] === OperationTypeEnum::getter) { + return $tool['getter']->uriKey() === $operationName; + } + + return false; + }); + }) + ->first(); + + if (! $tool) { + throw new \InvalidArgumentException("Operation '{$operationType}' not found for repository '{$repositoryKey}'"); + } + + // Check permission before executing + if (! $this->canUse($tool['instance'])) { + throw new \Illuminate\Auth\Access\AuthorizationException('Not authorized to execute this operation'); + } + + // Execute based on operation type + return match ($operationTypeEnum) { + OperationTypeEnum::index => $this->executeIndexOperation($tool, $parameters), + OperationTypeEnum::show => $this->executeShowOperation($tool, $parameters), + OperationTypeEnum::store => $this->executeStoreOperation($tool, $parameters), + OperationTypeEnum::update => $this->executeUpdateOperation($tool, $parameters), + OperationTypeEnum::delete => $this->executeDeleteOperation($tool, $parameters), + OperationTypeEnum::profile => $this->executeProfileOperation($tool, $parameters), + OperationTypeEnum::action => $this->executeActionOperation($tool, $parameters), + OperationTypeEnum::getter => $this->executeGetterOperation($tool, $parameters), + default => throw new \InvalidArgumentException("Invalid operation type: {$operationType}"), + }; + } + + protected function executeIndexOperation(array $tool, array $parameters): Response + { + $request = new Request($parameters); + + return $tool['instance']->handle($request); + } + + protected function executeShowOperation(array $tool, array $parameters): Response + { + $request = new Request($parameters); + + return $tool['instance']->handle($request); + } + + protected function executeStoreOperation(array $tool, array $parameters): Response + { + $request = new Request($parameters); + + return $tool['instance']->handle($request); + } + + protected function executeUpdateOperation(array $tool, array $parameters): Response + { + $request = new Request($parameters); + + return $tool['instance']->handle($request); + } + + protected function executeDeleteOperation(array $tool, array $parameters): Response + { + $request = new Request($parameters); + + return $tool['instance']->handle($request); + } + + protected function executeProfileOperation(array $tool, array $parameters): Response + { + $request = new Request($parameters); + + return $tool['instance']->handle($request); + } + + protected function executeActionOperation(array $tool, array $parameters): Response + { + $request = new Request($parameters); + + return $tool['instance']->handle($request); + } + + protected function executeGetterOperation(array $tool, array $parameters): Response + { + $request = new Request($parameters); + + return $tool['instance']->handle($request); + } } diff --git a/src/MCP/RestifyServer.php b/src/MCP/RestifyServer.php index c00f1165..d84ac7b0 100644 --- a/src/MCP/RestifyServer.php +++ b/src/MCP/RestifyServer.php @@ -56,15 +56,24 @@ * ## How Permissions Work in Wrapper Mode * * Even though wrapper mode only registers 4 wrapper tools with the MCP server, - * ALL individual operations are still discovered and available for permission checks: + * ALL individual operations are discovered and filtered by your `canUseTool()` method. * - * 1. AI calls wrapper tool: `execute-operation(repository="posts", operation="store")` - * 2. Wrapper tool looks up "posts-store" in McpTools - * 3. Before execution, calls `canUseTool("posts-store")` - * 4. Your `canUseTool()` implementation checks if token has "posts-store" permission - * 5. If yes, executes; if no, throws AuthorizationException + * **Discovery Phase** (AI explores available operations): + * 1. AI calls `discover-repositories` → Shows only repositories with ≥1 accessible operation + * 2. AI calls `get-repository-operations(repository="posts")` → Lists only operations user can access + * - If token lacks "posts-store" permission, store won't appear in the list + * - Actions/getters are also filtered by permission + * 3. AI calls `get-operation-details(repository="posts", operation="store")` → Checks `canUseTool("posts-store")` + * - If no permission, throws AuthorizationException * - * This allows fine-grained permissions even in wrapper mode! + * **Execution Phase** (AI executes an operation): + * 4. AI calls `execute-operation(repository="posts", operation="store", parameters={...})` + * 5. Wrapper looks up "posts-store" tool in McpTools + * 6. Calls `canUseTool("posts-store")` before execution + * 7. If yes → executes, if no → throws AuthorizationException + * + * **Key Point**: Your `canUseTool()` method is called during BOTH discovery and execution, + * ensuring users only see and can execute operations they have permission for. * * ## Getting Authorized Tools at Runtime * @@ -76,28 +85,17 @@ * ``` */ -use Binaryk\LaravelRestify\Actions\Action; -use Binaryk\LaravelRestify\Getters\Getter; -use Binaryk\LaravelRestify\MCP\Bootstrap\BootMcpTools; -use Binaryk\LaravelRestify\MCP\Concerns\HasMcpTools; -use Binaryk\LaravelRestify\MCP\Requests\McpActionRequest; -use Binaryk\LaravelRestify\MCP\Requests\McpGetterRequest; +use Binaryk\LaravelRestify\MCP\Collections\ToolsCollection; +use Binaryk\LaravelRestify\MCP\Enums\ToolsCategoryEnum; use Binaryk\LaravelRestify\MCP\Resources\ApplicationInfo; -use Binaryk\LaravelRestify\MCP\Tools\Operations\ActionTool; -use Binaryk\LaravelRestify\MCP\Tools\Operations\DeleteTool; -use Binaryk\LaravelRestify\MCP\Tools\Operations\GetterTool; -use Binaryk\LaravelRestify\MCP\Tools\Operations\IndexTool; -use Binaryk\LaravelRestify\MCP\Tools\Operations\ProfileTool; -use Binaryk\LaravelRestify\MCP\Tools\Operations\ShowTool; -use Binaryk\LaravelRestify\MCP\Tools\Operations\StoreTool; -use Binaryk\LaravelRestify\MCP\Tools\Operations\UpdateTool; use Binaryk\LaravelRestify\MCP\Tools\Wrapper\DiscoverRepositoriesTool; use Binaryk\LaravelRestify\MCP\Tools\Wrapper\ExecuteOperationTool; use Binaryk\LaravelRestify\MCP\Tools\Wrapper\GetOperationDetailsTool; use Binaryk\LaravelRestify\MCP\Tools\Wrapper\GetRepositoryOperationsTool; -use Binaryk\LaravelRestify\Repositories\Repository; -use Binaryk\LaravelRestify\Restify; +use Illuminate\Support\Collection; use Laravel\Mcp\Server; +use Laravel\Mcp\Server\Prompt; +use Laravel\Mcp\Server\Tool; class RestifyServer extends Server { @@ -129,7 +127,7 @@ class RestifyServer extends Server /** * The tools registered with this MCP server. * - * @var array> + * @var array> */ protected array $tools = []; @@ -145,7 +143,7 @@ class RestifyServer extends Server /** * The prompts registered with this MCP server. * - * @var array> + * @var array> */ protected array $prompts = []; @@ -156,42 +154,43 @@ protected function boot(): void McpTools::setServer($this); // Register tools with the server - collect($this->discoverTools())->each(fn (string $tool): string => $this->tools[] = $tool); - $this->discoverRepositoryTools(); + $this->discoverTools()->each(fn (string $tool): string => $this->tools[] = $tool); + $this->registerRepositoryTools(); collect($this->discoverResources())->each(fn (string $resource): string => $this->resources[] = $resource); collect($this->discoverPrompts())->each(fn (string $prompt): string => $this->prompts[] = $prompt); } /** - * @return array> + * @return array> */ - protected function discoverTools(): array + protected function discoverTools(): ToolsCollection { - return McpTools::category('Custom Tools') + return McpTools::category(ToolsCategoryEnum::CUSTOM_TOOLS->value) ->filter(fn (array $tool): bool => $this->canUseTool($tool['instance'])) - ->pluck('class') - ->toArray(); + ->pluck('class'); } - protected function discoverRepositoryTools(): void + protected function registerRepositoryTools(): void { - // Check if we should use wrapper mode - if (config('restify.mcp.mode') === 'wrapper') { + $mode = request()->get('mode', config('restify.mcp.mode')); + + if ($mode === 'wrapper') { $this->registerWrapperTools(); return; } - // Direct mode - register each operation as a separate tool McpTools::all() - ->whereIn('category', ['CRUD Operations', 'Actions', 'Getters', 'Profile']) + ->whereIn('category', [ + ToolsCategoryEnum::CRUD_OPERATIONS->value, + ToolsCategoryEnum::ACTIONS->value, + ToolsCategoryEnum::GETTERS->value, + ToolsCategoryEnum::PROFILE->value, + ]) ->filter(fn (array $tool): bool => $this->canUseTool($tool['instance'])) ->each(fn (array $tool) => $this->tools[] = $tool['instance']); } - /** - * Register wrapper tools for progressive discovery mode. - */ protected function registerWrapperTools(): void { $this->tools[] = DiscoverRepositoriesTool::class; @@ -244,7 +243,7 @@ protected function discoverResources(): array } /** - * @return array> + * @return array> */ protected function discoverPrompts(): array { @@ -293,29 +292,16 @@ public function canUseTool(string|object $tool): bool * all individual operations are still discovered and available for permission checks. * This allows you to create tokens with fine-grained permissions for specific operations. */ - public function getAllAvailableTools(): \Illuminate\Support\Collection + public function getAllAvailableTools(): Collection { - return McpTools::all()->map(fn (array $tool): array => [ - 'name' => $tool['name'], - 'title' => $tool['title'], - 'description' => $tool['description'], - 'category' => $tool['category'], - 'type' => $tool['type'], - ]); + return McpTools::all()->toUi(); } /** * Get tools that the current user/token is authorized to use. */ - public function getAuthorizedTools(): \Illuminate\Support\Collection + public function getAuthorizedTools(): Collection { - return McpTools::authorized()->map(fn (array $tool): array => [ - 'name' => $tool['name'], - 'title' => $tool['title'], - 'description' => $tool['description'], - 'category' => $tool['category'], - 'type' => $tool['type'], - ]); + return McpTools::authorized()->toUi(); } - } diff --git a/src/MCP/Services/ToolRegistry.php b/src/MCP/Services/ToolRegistry.php deleted file mode 100644 index b99c0651..00000000 --- a/src/MCP/Services/ToolRegistry.php +++ /dev/null @@ -1,297 +0,0 @@ -server ??= McpTools::server(); - } - - /** - * Get all available repositories with their MCP-enabled operations. - */ - public function getAvailableRepositories(?string $search = null): Collection - { - // Get all repository tools from McpTools facade - $repositories = McpTools::authorized() - ->filter(fn (array $tool): bool => isset($tool['repository'])) - ->groupBy('repository') - ->map(function (Collection $tools, string $repositoryKey): array { - $firstTool = $tools->first(); - - // Count operations by type - $operations = $tools->whereIn('type', ['index', 'show', 'store', 'update', 'delete', 'profile'])->pluck('type')->values()->toArray(); - $actionsCount = $tools->where('type', 'action')->count(); - $gettersCount = $tools->where('type', 'getter')->count(); - - // Get repository metadata from the first tool instance - $repository = app($firstTool['instance']->repository ?? Repository::class); - - return [ - 'name' => $repositoryKey, - 'label' => $firstTool['title'] ?? $repositoryKey, - 'description' => $firstTool['description'] ?? '', - 'model' => class_basename($repository::guessModelClassName()), - 'operations' => $operations, - 'actions_count' => $actionsCount, - 'getters_count' => $gettersCount, - ]; - }) - ->values(); - - if ($search) { - $search = strtolower($search); - $repositories = $repositories->filter(function ($repo) use ($search) { - return str_contains(strtolower($repo['name']), $search) || - str_contains(strtolower($repo['label']), $search) || - str_contains(strtolower($repo['description'] ?? ''), $search); - }); - } - - return $repositories->values(); - } - - /** - * Get all operations available for a specific repository. - */ - public function getRepositoryOperations(string $repositoryKey): array - { - // Get all tools for this repository from McpTools - $tools = McpTools::repository($repositoryKey); - - if ($tools->isEmpty()) { - throw new \InvalidArgumentException("Repository '{$repositoryKey}' does not have MCP tools enabled. Add the HasMcpTools trait to enable MCP support."); - } - - // Filter by permissions - $tools = $tools->filter(fn (array $tool): bool => McpTools::canUse($tool['instance'])); - - if ($tools->isEmpty()) { - throw new \InvalidArgumentException("Repository '{$repositoryKey}' found, but you don't have permission to access any of its operations"); - } - - $firstTool = $tools->first(); - - // Build operations list - $operations = $tools->whereIn('type', ['index', 'show', 'store', 'update', 'delete', 'profile']) - ->map(fn (array $tool): array => [ - 'type' => $tool['type'], - 'name' => $tool['name'], - 'title' => $tool['title'], - 'description' => $tool['description'], - ]) - ->values() - ->toArray(); - - // Build actions list - $actions = $tools->where('type', 'action') - ->map(fn (array $tool): array => [ - 'type' => 'action', - 'name' => $tool['action']->uriKey(), - 'tool_name' => $tool['name'], - 'title' => $tool['title'], - 'description' => $tool['description'], - ]) - ->values() - ->toArray(); - - // Build getters list - $getters = $tools->where('type', 'getter') - ->map(fn (array $tool): array => [ - 'type' => 'getter', - 'name' => $tool['getter']->uriKey(), - 'tool_name' => $tool['name'], - 'title' => $tool['title'], - 'description' => $tool['description'], - ]) - ->values() - ->toArray(); - - return [ - 'repository' => $repositoryKey, - 'label' => $firstTool['title'], - 'description' => $firstTool['description'], - 'operations' => $operations, - 'actions' => $actions, - 'getters' => $getters, - ]; - } - - /** - * Get detailed information about a specific operation. - */ - public function getOperationDetails(string $repositoryKey, string $operationType, ?string $operationName = null): array - { - // Check if repository has MCP tools - if (McpTools::repository($repositoryKey)->isEmpty()) { - throw new \InvalidArgumentException("Repository '{$repositoryKey}' does not have MCP tools enabled. Add the HasMcpTools trait to enable MCP support."); - } - - // Find the tool from McpTools - $tool = McpTools::repository($repositoryKey) - ->where('type', $operationType) - ->when($operationName && in_array($operationType, ['action', 'getter']), function ($collection) use ($operationName) { - return $collection->filter(function ($tool) use ($operationName) { - if ($tool['type'] === 'action') { - return $tool['action']->uriKey() === $operationName; - } - if ($tool['type'] === 'getter') { - return $tool['getter']->uriKey() === $operationName; - } - - return false; - }); - }) - ->first(); - - if (! $tool) { - throw new \InvalidArgumentException("Operation '{$operationType}' not found for repository '{$repositoryKey}'"); - } - - // Check permission - if (! McpTools::canUse($tool['instance'])) { - throw new \Illuminate\Auth\Access\AuthorizationException('Not authorized to access this operation'); - } - - // Get schema from the tool instance - $schema = new JsonSchemaTypeFactory; - $toolSchema = $tool['instance']->schema($schema); - - return [ - 'operation' => $tool['name'], - 'type' => $tool['type'], - 'title' => $tool['title'], - 'description' => $tool['description'], - 'schema' => $toolSchema, - ]; - } - - /** - * Execute an operation with the provided parameters. - */ - public function executeOperation(string $repositoryKey, string $operationType, ?string $operationName, array $parameters): Response - { - // Check if repository has MCP tools - if (McpTools::repository($repositoryKey)->isEmpty()) { - throw new \InvalidArgumentException("Repository '{$repositoryKey}' does not have MCP tools enabled. Add the HasMcpTools trait to enable MCP support."); - } - - // Find the tool from McpTools - $tool = McpTools::repository($repositoryKey) - ->where('type', $operationType) - ->when($operationName && in_array($operationType, ['action', 'getter']), function ($collection) use ($operationName) { - return $collection->filter(function ($tool) use ($operationName) { - if ($tool['type'] === 'action') { - return $tool['action']->uriKey() === $operationName; - } - if ($tool['type'] === 'getter') { - return $tool['getter']->uriKey() === $operationName; - } - - return false; - }); - }) - ->first(); - - if (! $tool) { - throw new \InvalidArgumentException("Operation '{$operationType}' not found for repository '{$repositoryKey}'"); - } - - // Check permission before executing - if (! McpTools::canUse($tool['instance'])) { - throw new \Illuminate\Auth\Access\AuthorizationException('Not authorized to execute this operation'); - } - - // Execute based on operation type - return match ($operationType) { - 'index' => $this->executeIndexOperation($tool, $parameters), - 'show' => $this->executeShowOperation($tool, $parameters), - 'store' => $this->executeStoreOperation($tool, $parameters), - 'update' => $this->executeUpdateOperation($tool, $parameters), - 'delete' => $this->executeDeleteOperation($tool, $parameters), - 'profile' => $this->executeProfileOperation($tool, $parameters), - 'action' => $this->executeActionOperation($tool, $parameters), - 'getter' => $this->executeGetterOperation($tool, $parameters), - default => throw new \InvalidArgumentException("Invalid operation type: {$operationType}"), - }; - } - - - protected function executeIndexOperation(array $tool, array $parameters): Response - { - $request = new Request($parameters); - - return $tool['instance']->handle($request); - } - - protected function executeShowOperation(array $tool, array $parameters): Response - { - $request = new Request($parameters); - - return $tool['instance']->handle($request); - } - - protected function executeStoreOperation(array $tool, array $parameters): Response - { - $request = new Request($parameters); - - return $tool['instance']->handle($request); - } - - protected function executeUpdateOperation(array $tool, array $parameters): Response - { - $request = new Request($parameters); - - return $tool['instance']->handle($request); - } - - protected function executeDeleteOperation(array $tool, array $parameters): Response - { - $request = new Request($parameters); - - return $tool['instance']->handle($request); - } - - protected function executeProfileOperation(array $tool, array $parameters): Response - { - $request = new Request($parameters); - - return $tool['instance']->handle($request); - } - - protected function executeActionOperation(array $tool, array $parameters): Response - { - $request = new Request($parameters); - - return $tool['instance']->handle($request); - } - - protected function executeGetterOperation(array $tool, array $parameters): Response - { - $request = new Request($parameters); - - return $tool['instance']->handle($request); - } -} diff --git a/src/MCP/Tools/Wrapper/DiscoverRepositoriesTool.php b/src/MCP/Tools/Wrapper/DiscoverRepositoriesTool.php index 1cdaef3c..e4c1ac8e 100644 --- a/src/MCP/Tools/Wrapper/DiscoverRepositoriesTool.php +++ b/src/MCP/Tools/Wrapper/DiscoverRepositoriesTool.php @@ -3,7 +3,7 @@ namespace Binaryk\LaravelRestify\MCP\Tools\Wrapper; use Binaryk\LaravelRestify\MCP\Concerns\WrapperToolHelpers; -use Binaryk\LaravelRestify\MCP\Services\ToolRegistry; +use Binaryk\LaravelRestify\MCP\McpTools; use Illuminate\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; @@ -34,10 +34,9 @@ public function schema(JsonSchema $schema): array public function handle(Request $request): Response { try { - $registry = app(ToolRegistry::class); $search = $request->get('search'); - $repositories = $registry->getAvailableRepositories($search); + $repositories = McpTools::getAvailableRepositories($search); return Response::json([ 'success' => true, diff --git a/src/MCP/Tools/Wrapper/ExecuteOperationTool.php b/src/MCP/Tools/Wrapper/ExecuteOperationTool.php index 4b0e8bac..6c7938aa 100644 --- a/src/MCP/Tools/Wrapper/ExecuteOperationTool.php +++ b/src/MCP/Tools/Wrapper/ExecuteOperationTool.php @@ -3,7 +3,7 @@ namespace Binaryk\LaravelRestify\MCP\Tools\Wrapper; use Binaryk\LaravelRestify\MCP\Concerns\WrapperToolHelpers; -use Binaryk\LaravelRestify\MCP\Services\ToolRegistry; +use Binaryk\LaravelRestify\MCP\McpTools; use Illuminate\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; @@ -45,8 +45,6 @@ public function schema(JsonSchema $schema): array public function handle(Request $request): Response { try { - $registry = app(ToolRegistry::class); - $repositoryKey = $request->get('repository'); $operationType = $request->get('operation_type'); $operationName = $request->get('operation_name'); @@ -81,7 +79,7 @@ public function handle(Request $request): Response } // Execute the operation through the registry - $result = $registry->executeOperation($repositoryKey, $operationType, $operationName, $parameters); + $result = McpTools::executeOperation($repositoryKey, $operationType, $operationName, $parameters); return $result; } catch (\InvalidArgumentException $e) { diff --git a/src/MCP/Tools/Wrapper/GetOperationDetailsTool.php b/src/MCP/Tools/Wrapper/GetOperationDetailsTool.php index 6ba91e1e..4149dd9a 100644 --- a/src/MCP/Tools/Wrapper/GetOperationDetailsTool.php +++ b/src/MCP/Tools/Wrapper/GetOperationDetailsTool.php @@ -3,7 +3,7 @@ namespace Binaryk\LaravelRestify\MCP\Tools\Wrapper; use Binaryk\LaravelRestify\MCP\Concerns\WrapperToolHelpers; -use Binaryk\LaravelRestify\MCP\Services\ToolRegistry; +use Binaryk\LaravelRestify\MCP\McpTools; use Illuminate\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; @@ -42,8 +42,6 @@ public function schema(JsonSchema $schema): array public function handle(Request $request): Response { try { - $registry = app(ToolRegistry::class); - $repositoryKey = $request->get('repository'); $operationType = $request->get('operation_type'); $operationName = $request->get('operation_name'); @@ -69,7 +67,7 @@ public function handle(Request $request): Response )); } - $details = $registry->getOperationDetails($repositoryKey, $operationType, $operationName); + $details = McpTools::getOperationDetails($repositoryKey, $operationType, $operationName); // Format schema for better readability $formattedSchema = $this->formatSchemaForDisplay($details['schema']); diff --git a/src/MCP/Tools/Wrapper/GetRepositoryOperationsTool.php b/src/MCP/Tools/Wrapper/GetRepositoryOperationsTool.php index d01af132..c3cb3db7 100644 --- a/src/MCP/Tools/Wrapper/GetRepositoryOperationsTool.php +++ b/src/MCP/Tools/Wrapper/GetRepositoryOperationsTool.php @@ -3,7 +3,7 @@ namespace Binaryk\LaravelRestify\MCP\Tools\Wrapper; use Binaryk\LaravelRestify\MCP\Concerns\WrapperToolHelpers; -use Binaryk\LaravelRestify\MCP\Services\ToolRegistry; +use Binaryk\LaravelRestify\MCP\McpTools; use Illuminate\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; @@ -35,7 +35,6 @@ public function schema(JsonSchema $schema): array public function handle(Request $request): Response { try { - $registry = app(ToolRegistry::class); $repositoryKey = $request->get('repository'); if (! $repositoryKey) { @@ -45,7 +44,7 @@ public function handle(Request $request): Response )); } - $operations = $registry->getRepositoryOperations($repositoryKey); + $operations = McpTools::getRepositoryOperations($repositoryKey); $nextSteps = []; From a187d0a86c53258932241de419cc170ef8101604 Mon Sep 17 00:00:00 2001 From: binaryk Date: Mon, 27 Oct 2025 09:21:51 +0000 Subject: [PATCH 4/7] Fix styling --- src/MCP/Bootstrap/BootMcpTools.php | 20 ++++++++++---------- src/MCP/Collections/ToolsCollection.php | 2 -- src/MCP/Enums/ToolsCategoryEnum.php | 14 +++++++------- 3 files changed, 17 insertions(+), 19 deletions(-) diff --git a/src/MCP/Bootstrap/BootMcpTools.php b/src/MCP/Bootstrap/BootMcpTools.php index 9c2a6297..c84b7337 100644 --- a/src/MCP/Bootstrap/BootMcpTools.php +++ b/src/MCP/Bootstrap/BootMcpTools.php @@ -167,8 +167,8 @@ protected function discoverCustomTools(): Collection protected function discoverRepositoryTools(): Collection { return collect(Restify::$repositories) - ->filter(fn(string $repo): bool => in_array(HasMcpTools::class, class_uses_recursive($repo))) - ->flatMap(fn(string $repoClass): Collection => $this->discoverRepositoryOperations($repoClass)) + ->filter(fn (string $repo): bool => in_array(HasMcpTools::class, class_uses_recursive($repo))) + ->flatMap(fn (string $repoClass): Collection => $this->discoverRepositoryOperations($repoClass)) ->values(); } @@ -238,10 +238,10 @@ protected function discoverActions(string $repositoryClass, Repository $reposito $actionRequest = app(McpActionRequest::class); return $repository->resolveActions($actionRequest) - ->filter(fn($action): bool => $action instanceof Action) - ->filter(fn(Action $action): bool => $action->isShownOnMcp($actionRequest, $repository)) - ->filter(fn(Action $action): bool => $action->authorizedToSee($actionRequest)) - ->unique(fn(Action $action): string => $action->uriKey()) + ->filter(fn ($action): bool => $action instanceof Action) + ->filter(fn (Action $action): bool => $action->isShownOnMcp($actionRequest, $repository)) + ->filter(fn (Action $action): bool => $action->authorizedToSee($actionRequest)) + ->unique(fn (Action $action): string => $action->uriKey()) ->map(function (Action $action) use ($repositoryClass, $repository): array { $instance = new ActionTool($repositoryClass, $action); @@ -268,10 +268,10 @@ protected function discoverGetters(string $repositoryClass, Repository $reposito $getterRequest = app(McpGetterRequest::class); return $repository->resolveGetters($getterRequest) - ->filter(fn($getter): bool => $getter instanceof Getter) - ->filter(fn(Getter $getter): bool => $getter->isShownOnMcp($getterRequest, $repository)) - ->filter(fn(Getter $getter): bool => $getter->authorizedToSee($getterRequest)) - ->unique(fn(Getter $getter): string => $getter->uriKey()) + ->filter(fn ($getter): bool => $getter instanceof Getter) + ->filter(fn (Getter $getter): bool => $getter->isShownOnMcp($getterRequest, $repository)) + ->filter(fn (Getter $getter): bool => $getter->authorizedToSee($getterRequest)) + ->unique(fn (Getter $getter): string => $getter->uriKey()) ->map(function (Getter $getter) use ($repositoryClass, $repository): array { $instance = new GetterTool($repositoryClass, $getter); diff --git a/src/MCP/Collections/ToolsCollection.php b/src/MCP/Collections/ToolsCollection.php index ec94bb2d..c235436b 100644 --- a/src/MCP/Collections/ToolsCollection.php +++ b/src/MCP/Collections/ToolsCollection.php @@ -4,7 +4,6 @@ use Binaryk\LaravelRestify\MCP\Enums\OperationTypeEnum; use Binaryk\LaravelRestify\MCP\Enums\ToolsCategoryEnum; -use Binaryk\LaravelRestify\MCP\Tools\Operations\StoreTool; use Illuminate\Support\Collection; use Laravel\Mcp\Server\Tool; @@ -34,5 +33,4 @@ public function pushTool(Tool $tool, string $repositoryKey, array $extra = []): 'category' => ToolsCategoryEnum::fromTool($tool)->value, ], $extra)); } - } diff --git a/src/MCP/Enums/ToolsCategoryEnum.php b/src/MCP/Enums/ToolsCategoryEnum.php index 5b8d9829..cf46aba6 100644 --- a/src/MCP/Enums/ToolsCategoryEnum.php +++ b/src/MCP/Enums/ToolsCategoryEnum.php @@ -29,17 +29,17 @@ public static function fromTool(Tool $tool): self { return match (true) { $tool instanceof IndexTool, - $tool instanceof ShowTool, - $tool instanceof StoreTool, - $tool instanceof UpdateTool, - $tool instanceof DeleteTool => self::CRUD_OPERATIONS, + $tool instanceof ShowTool, + $tool instanceof StoreTool, + $tool instanceof UpdateTool, + $tool instanceof DeleteTool => self::CRUD_OPERATIONS, $tool instanceof ProfileTool => self::PROFILE, $tool instanceof ActionTool => self::ACTIONS, $tool instanceof GetterTool => self::GETTERS, $tool instanceof GetOperationDetailsTool, - $tool instanceof ExecuteOperationTool, - $tool instanceof DiscoverRepositoriesTool, - $tool instanceof GetRepositoryOperationsTool => self::WRAPPER_TOOLS, + $tool instanceof ExecuteOperationTool, + $tool instanceof DiscoverRepositoriesTool, + $tool instanceof GetRepositoryOperationsTool => self::WRAPPER_TOOLS, default => self::CUSTOM_TOOLS, }; } From aa53f960358f11a69f5008cb489e228d1c597b58 Mon Sep 17 00:00:00 2001 From: Eduard Lupacescu Date: Mon, 27 Oct 2025 16:31:53 +0200 Subject: [PATCH 5/7] fix: fixing the mcp token --- src/MCP/Collections/ToolsCollection.php | 16 +++++++++++++++- src/MCP/RestifyServer.php | 4 ++-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/MCP/Collections/ToolsCollection.php b/src/MCP/Collections/ToolsCollection.php index c235436b..c30358de 100644 --- a/src/MCP/Collections/ToolsCollection.php +++ b/src/MCP/Collections/ToolsCollection.php @@ -16,7 +16,7 @@ public function toUi(): self 'title' => $tool['title'], 'description' => $tool['description'], 'category' => $tool['category'], - 'type' => $tool['type'], + 'type' => $tool['type'] instanceof OperationTypeEnum ? $tool['type']->name : $tool['type'], ]); } @@ -33,4 +33,18 @@ public function pushTool(Tool $tool, string $repositoryKey, array $extra = []): 'category' => ToolsCategoryEnum::fromTool($tool)->value, ], $extra)); } + + public function toSelectOptions(): array + { + return $this->groupBy('category') + ->map(fn ($tools, $category) => [ + 'category' => $category, + 'tools' => $tools->map(fn ($tool) => [ + 'name' => $tool['name'], + 'description' => $tool['description'], + ])->values(), + ]) + ->values() + ->toArray(); + } } diff --git a/src/MCP/RestifyServer.php b/src/MCP/RestifyServer.php index d84ac7b0..0f399ec9 100644 --- a/src/MCP/RestifyServer.php +++ b/src/MCP/RestifyServer.php @@ -292,7 +292,7 @@ public function canUseTool(string|object $tool): bool * all individual operations are still discovered and available for permission checks. * This allows you to create tokens with fine-grained permissions for specific operations. */ - public function getAllAvailableTools(): Collection + public function getAllAvailableTools(): ToolsCollection { return McpTools::all()->toUi(); } @@ -300,7 +300,7 @@ public function getAllAvailableTools(): Collection /** * Get tools that the current user/token is authorized to use. */ - public function getAuthorizedTools(): Collection + public function getAuthorizedTools(): ToolsCollection { return McpTools::authorized()->toUi(); } From 01a68c1258a552918ed529cf535fe5b800325592 Mon Sep 17 00:00:00 2001 From: binaryk Date: Mon, 27 Oct 2025 14:32:25 +0000 Subject: [PATCH 6/7] Fix styling --- src/MCP/RestifyServer.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/MCP/RestifyServer.php b/src/MCP/RestifyServer.php index 0f399ec9..8f03b22a 100644 --- a/src/MCP/RestifyServer.php +++ b/src/MCP/RestifyServer.php @@ -92,7 +92,6 @@ use Binaryk\LaravelRestify\MCP\Tools\Wrapper\ExecuteOperationTool; use Binaryk\LaravelRestify\MCP\Tools\Wrapper\GetOperationDetailsTool; use Binaryk\LaravelRestify\MCP\Tools\Wrapper\GetRepositoryOperationsTool; -use Illuminate\Support\Collection; use Laravel\Mcp\Server; use Laravel\Mcp\Server\Prompt; use Laravel\Mcp\Server\Tool; From 071d16b0152517aaa669a4dbda0c8115201ede82 Mon Sep 17 00:00:00 2001 From: Eduard Lupacescu Date: Mon, 27 Oct 2025 16:38:53 +0200 Subject: [PATCH 7/7] fix: docs --- docs-v3/content/docs/mcp/mcp.md | 217 ++++++++++++++++++++++++++++++++ 1 file changed, 217 insertions(+) diff --git a/docs-v3/content/docs/mcp/mcp.md b/docs-v3/content/docs/mcp/mcp.md index 199cf1b7..3399735e 100644 --- a/docs-v3/content/docs/mcp/mcp.md +++ b/docs-v3/content/docs/mcp/mcp.md @@ -415,3 +415,220 @@ No code changes are required. The MCP server automatically adapts to the configu 3. **Use wrapper mode** when working with AI agents that have limited context windows 4. **Monitor token usage** to determine which mode is best for your application 5. **Document your choice** so team members understand which mode is active + +## Fine-Grained Tool Permissions + +Laravel Restify's MCP integration includes a powerful permission system that allows you to control which tools each API token can access. This is essential for multi-tenant applications or when you need to restrict AI agent capabilities. + +### How Permission Control Works + +The `RestifyServer` class provides a `canUseTool()` method that is called whenever a tool is accessed. By default, this method returns `true` (all tools are accessible), but you can override it in your application server to implement custom permission logic. + +**Key Behavior:** +- `canUseTool()` is called during **tool discovery** (what tools the AI agent sees) +- `canUseTool()` is called during **tool execution** (whether the operation is allowed) +- In **wrapper mode**, permissions are checked for individual operations, not just the 4 wrapper tools +- Tools without permission are completely hidden from the AI agent + +### Implementing Token-Based Permissions + +Create a custom MCP server that extends `RestifyServer` and implements permission checks: + +```php +name(); + + // Get the API token from the request + $bearerToken = request()->bearerToken(); + + if (!$bearerToken) { + return false; + } + + // Find the MCP token record + $mcpToken = McpToken::where('token', hash('sha256', $bearerToken)) + ->first(); + + if (!$mcpToken) { + return false; + } + + // Check if this tool is in the token's allowed tools list + // $mcpToken->allowed_tools is a JSON array like: + // ["users-index", "posts-store", "posts-update-status-action"] + return in_array($toolName, $mcpToken->allowed_tools ?? [], true); + } +} +``` + +Then register your custom server instead of the base `RestifyServer`: + +```php +use App\Mcp\ApplicationServer; +use Laravel\Mcp\Facades\Mcp; + +Mcp::web('restify', ApplicationServer::class)->name('mcp.restify'); +``` + +### Generating Tokens with Tool Selection + +To create a token creation UI, you need to show users which tools are available and let them select which ones to grant access to: + +```php +use Binaryk\LaravelRestify\MCP\RestifyServer; + +// Get all available tools +$server = app(RestifyServer::class); +$allTools = $server->getAllAvailableTools(); + +// Returns a collection with all tools, regardless of mode: +// [ +// ['name' => 'users-index', 'title' => 'List Users', 'description' => '...', 'category' => 'CRUD Operations'], +// ['name' => 'users-store', 'title' => 'Create User', 'description' => '...', 'category' => 'CRUD Operations'], +// ['name' => 'posts-publish-action', 'title' => 'Publish Post', 'description' => '...', 'category' => 'Actions'], +// ] + +// Group tools by category for better UI +$groupedTools = $allTools->toSelectOptions(); + +// Returns: +// [ +// ['category' => 'CRUD Operations', 'tools' => [...]], +// ['category' => 'Actions', 'tools' => [...]], +// ['category' => 'Getters', 'tools' => [...]], +// ] +``` + +### Example Token Creation Flow + +Here's a complete example of creating a token with specific tool permissions: + +```php +use App\Models\McpToken; +use Illuminate\Support\Str; + +// 1. Show available tools to user +$server = app(RestifyServer::class); +$availableTools = $server->getAllAvailableTools()->toSelectOptions(); + +// 2. User selects which tools to grant access to +$selectedTools = [ + 'users-index', + 'users-show', + 'posts-index', + 'posts-store', + 'posts-publish-action', +]; + +// 3. Generate the token +$plainTextToken = Str::random(64); + +// 4. Store token with permissions +$mcpToken = McpToken::create([ + 'name' => 'AI Agent Token', + 'token' => hash('sha256', $plainTextToken), + 'allowed_tools' => $selectedTools, // Cast to JSON in model + 'user_id' => auth()->id(), + 'expires_at' => now()->addDays(30), +]); + +// 5. Return plain text token to user (only shown once) +return response()->json([ + 'token' => $plainTextToken, + 'allowed_tools' => $selectedTools, +]); +``` + +### Database Schema Example + +Here's a suggested database schema for storing MCP tokens with permissions: + +```php +Schema::create('mcp_tokens', function (Blueprint $table) { + $table->id(); + $table->string('name'); // Token description + $table->string('token')->unique(); // Hashed token + $table->json('allowed_tools')->nullable(); // Array of tool names + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamps(); +}); +``` + +And the corresponding Eloquent model: + +```php +namespace App\Models; + +use Illuminate\Database\Eloquent\Model; + +class McpToken extends Model +{ + protected $fillable = [ + 'name', + 'token', + 'allowed_tools', + 'user_id', + 'expires_at', + ]; + + protected $casts = [ + 'allowed_tools' => 'array', + 'expires_at' => 'datetime', + 'last_used_at' => 'datetime', + ]; + + public function user() + { + return $this->belongsTo(User::class); + } +} +``` + +### Permission Behavior in Different Modes + +**Direct Mode:** +- Tools without permission are filtered out of the tools list +- AI agent only sees tools they have access to +- Attempting to use a restricted tool results in "tool not found" + +**Wrapper Mode:** +- The 4 wrapper tools are always visible (discover, get-operations, get-details, execute) +- When discovering repositories, only those with ≥1 accessible operation are shown +- When listing operations, only permitted operations appear +- Attempting to get details or execute a restricted operation throws `AuthorizationException` + +### Getting Authorized Tools at Runtime + +You can get only the tools the current user/token can access: + +```php +$server = app(RestifyServer::class); +$authorizedTools = $server->getAuthorizedTools(); + +// Returns only tools that pass the canUseTool() check +// Useful for showing "Your API Access" in a dashboard +``` + +### Security Best Practices + +1. **Always hash tokens** before storing in the database (use `hash('sha256', $token)`) +2. **Generate tokens with sufficient entropy** (at least 64 random characters) +3. **Implement token expiration** and enforce it in `canUseTool()` +4. **Log token usage** by updating `last_used_at` on each request +5. **Revoke tokens** by deleting the database record +6. **Use HTTPS** to protect tokens in transit +7. **Implement rate limiting** per token to prevent abuse +8. **Audit permission changes** when updating `allowed_tools`