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` diff --git a/src/MCP/Bootstrap/BootMcpTools.php b/src/MCP/Bootstrap/BootMcpTools.php new file mode 100644 index 00000000..c84b7337 --- /dev/null +++ b/src/MCP/Bootstrap/BootMcpTools.php @@ -0,0 +1,292 @@ +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}"; + + 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()) + ->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' => OperationTypeEnum::custom, + 'name' => $instance->name(), + 'title' => $instance->title(), + 'description' => $instance->description(), + 'class' => $fqdn, + 'instance' => $instance, + 'category' => ToolsCategoryEnum::CUSTOM_TOOLS->value, + ]); + } + } + } + + // 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' => OperationTypeEnum::wrapper, + 'name' => $instance->name(), + 'title' => $instance->title(), + 'description' => $instance->description(), + 'class' => $fqdn, + 'instance' => $instance, + 'category' => ToolsCategoryEnum::WRAPPER_TOOLS->value, + ]); + } + } + } + } + + // 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' => OperationTypeEnum::custom, + 'name' => $instance->name(), + 'title' => $instance->title(), + 'description' => $instance->description(), + 'class' => $fqdn, + 'instance' => $instance, + 'category' => ToolsCategoryEnum::CUSTOM_TOOLS->value, + ]); + } + } + } + } + + // Extra tools from config + $extraTools = config('restify.mcp.tools.include', []); + foreach ($extraTools as $toolClass) { + if (class_exists($toolClass)) { + $instance = app($toolClass); + $tools->push([ + 'type' => OperationTypeEnum::custom, + 'name' => $instance->name(), + 'title' => $instance->title(), + 'description' => $instance->description(), + 'class' => $toolClass, + 'instance' => $instance, + 'category' => ToolsCategoryEnum::CUSTOM_TOOLS->value, + ]); + } + } + + 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): ToolsCollection + { + $repository = app($repositoryClass); + $tools = ToolsCollection::make(); + + if ($repository::uriKey() === 'users') { + $tools->pushTool( + new ProfileTool($repositoryClass), + $repository::uriKey() + ); + } + + if (method_exists($repository, 'mcpAllowsIndex') && $repository->mcpAllowsIndex()) { + $tools->pushTool( + new IndexTool($repositoryClass), + $repository::uriKey() + ); + } + + if (method_exists($repository, 'mcpAllowsShow') && $repository->mcpAllowsShow()) { + $tools->pushTool( + new ShowTool($repositoryClass), + $repository::uriKey() + ); + } + + if (method_exists($repository, 'mcpAllowsStore') && $repository->mcpAllowsStore()) { + $tools->pushTool( + new StoreTool($repositoryClass), + $repository::uriKey() + ); + } + + if (method_exists($repository, 'mcpAllowsUpdate') && $repository->mcpAllowsUpdate()) { + $tools->pushTool( + new UpdateTool($repositoryClass), + $repository::uriKey() + ); + } + + if (method_exists($repository, 'mcpAllowsDelete') && $repository->mcpAllowsDelete()) { + $tools->pushTool( + new DeleteTool($repositoryClass), + $repository::uriKey() + ); + } + + if (method_exists($repository, 'mcpAllowsActions') && $repository->mcpAllowsActions()) { + $tools = $tools->merge($this->discoverActions($repositoryClass, $repository)); + } + + if (method_exists($repository, 'mcpAllowsGetters') && $repository->mcpAllowsGetters()) { + $tools = $tools->merge($this->discoverGetters($repositoryClass, $repository)); + } + + return $tools; + } + + 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' => OperationTypeEnum::action, + 'name' => $instance->name(), + 'title' => $instance->title(), + 'description' => $instance->description(), + 'class' => ActionTool::class, + 'instance' => $instance, + 'repository' => $repository::uriKey(), + 'category' => ToolsCategoryEnum::ACTIONS->value, + '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' => OperationTypeEnum::getter, + 'name' => $instance->name(), + 'title' => $instance->title(), + 'description' => $instance->description(), + 'class' => GetterTool::class, + 'instance' => $instance, + 'repository' => $repository::uriKey(), + 'category' => ToolsCategoryEnum::GETTERS->value, + 'getter' => $getter, + ]; + }) + ->values(); + } +} diff --git a/src/MCP/Collections/ToolsCollection.php b/src/MCP/Collections/ToolsCollection.php new file mode 100644 index 00000000..c30358de --- /dev/null +++ b/src/MCP/Collections/ToolsCollection.php @@ -0,0 +1,50 @@ +map(fn (array $tool): array => [ + 'name' => $tool['name'], + 'title' => $tool['title'], + 'description' => $tool['description'], + 'category' => $tool['category'], + 'type' => $tool['type'] instanceof OperationTypeEnum ? $tool['type']->name : $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)); + } + + 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/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..cf46aba6 --- /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 new file mode 100644 index 00000000..cab891dd --- /dev/null +++ b/src/MCP/McpTools.php @@ -0,0 +1,39 @@ + + */ + 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(): ToolsCollection + { + if (! $this->discovered) { + $this->discover(); + } + + return ToolsCollection::make($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): ToolsCollection + { + return $this->all()->where('category', $category); + } + + /** + * Get tools for a specific repository. + */ + public function repository(string $repositoryKey): ToolsCollection + { + 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(): ToolsCollection + { + 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(): ToolsCollection + { + return $this->all()->groupBy('category'); + } + + /** + * Get tools grouped by repository. + */ + public function byRepository(): ToolsCollection + { + 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(); + } + + /** + * 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 448d0c86..8f03b22a 100644 --- a/src/MCP/RestifyServer.php +++ b/src/MCP/RestifyServer.php @@ -4,23 +4,97 @@ namespace Binaryk\LaravelRestify\MCP; -use Binaryk\LaravelRestify\Actions\Action; -use Binaryk\LaravelRestify\Getters\Getter; -use Binaryk\LaravelRestify\MCP\Concerns\HasMcpTools; -use Binaryk\LaravelRestify\MCP\Requests\McpActionRequest; -use Binaryk\LaravelRestify\MCP\Requests\McpGetterRequest; +/** + * 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 discovered and filtered by your `canUseTool()` method. + * + * **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 + * + * **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 + * + * To get only tools the current user/token can access: + * + * ```php + * $authorizedTools = $server->getAuthorizedTools(); + * // Returns tools filtered by canUseTool() + * ``` + */ + +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\Repositories\Repository; -use Binaryk\LaravelRestify\Restify; +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 Laravel\Mcp\Server; +use Laravel\Mcp\Server\Prompt; +use Laravel\Mcp\Server\Tool; class RestifyServer extends Server { @@ -52,7 +126,7 @@ class RestifyServer extends Server /** * The tools registered with this MCP server. * - * @var array> + * @var array> */ protected array $tools = []; @@ -68,146 +142,60 @@ class RestifyServer extends Server /** * The prompts registered with this MCP server. * - * @var array> + * @var array> */ protected array $prompts = []; protected function boot(): void { - collect($this->discoverTools())->each(fn (string $tool): string => $this->tools[] = $tool); - $this->discoverRepositoryTools(); + // 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 + $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 { - $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(ToolsCategoryEnum::CUSTOM_TOOLS->value) + ->filter(fn (array $tool): bool => $this->canUseTool($tool['instance'])) + ->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 - 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', [ + 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[] = \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; } /** @@ -254,7 +242,7 @@ protected function discoverResources(): array } /** - * @return array> + * @return array> */ protected function discoverPrompts(): array { @@ -282,4 +270,37 @@ 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(): ToolsCollection + { + return McpTools::all()->toUi(); + } + + /** + * Get tools that the current user/token is authorized to use. + */ + public function getAuthorizedTools(): ToolsCollection + { + return McpTools::authorized()->toUi(); + } } diff --git a/src/MCP/Services/ToolRegistry.php b/src/MCP/Services/ToolRegistry.php deleted file mode 100644 index 76f76d69..00000000 --- a/src/MCP/Services/ToolRegistry.php +++ /dev/null @@ -1,729 +0,0 @@ -buildRepositoriesMetadata(); - - 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 - { - $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); - - 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->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", - ]; - } - - 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 (method_exists($repository, 'mcpAllowsDelete') && $repository->mcpAllowsDelete()) { - $operations[] = [ - 'type' => 'delete', - 'name' => "{$repository::uriKey()}-delete-tool", - 'title' => "{$repositoryClass::label()} Delete", - 'description' => "Delete a {$repositoryClass::label()} record", - ]; - } - - return $operations; - } - - protected function buildRepositoryActions(Repository $repository, string $repositoryClass): array - { - if (! method_exists($repository, 'mcpAllowsActions') || ! $repository->mcpAllowsActions()) { - return []; - } - - $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()) - ->map(fn (Action $action) => [ - 'type' => 'action', - 'name' => $action->uriKey(), - 'tool_name' => "{$repository::uriKey()}-{$action->uriKey()}-action-tool", - 'title' => $action->name(), - 'description' => $action->description($actionRequest) ?? "Execute {$action->name()} action", - ]) - ->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) => [ - 'type' => 'getter', - 'name' => $getter->uriKey(), - 'tool_name' => "{$repository::uriKey()}-{$getter->uriKey()}-getter-tool", - 'title' => $getter->name(), - 'description' => $getter->description($getterRequest) ?? "Execute {$getter->name()} getter", - ]) - ->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), - ]; - } - - protected function getShowOperationDetails(Repository $repository, string $repositoryClass): 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"); - } - - return [ - 'operation' => "{$repository::uriKey()}-show-tool", - 'type' => 'show', - 'title' => "{$repositoryClass::label()} Show", - 'description' => "Show a specific {$repositoryClass::label()} record", - 'schema' => $repositoryClass::showToolSchema($schema), - ]; - } - - 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"); - } - - $schema = new JsonSchemaTypeFactory; - - if (! method_exists($repositoryClass, 'storeToolSchema')) { - throw new \InvalidArgumentException("Repository '{$repository::uriKey()}' does not support store operation"); - } - - 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"); - } - - $schema = new JsonSchemaTypeFactory; - - if (! method_exists($repositoryClass, 'updateToolSchema')) { - throw new \InvalidArgumentException("Repository '{$repository::uriKey()}' does not support update operation"); - } - - return [ - 'operation' => "{$repository::uriKey()}-update-tool", - 'type' => 'update', - 'title' => "{$repositoryClass::label()} Update", - 'description' => "Update an existing {$repositoryClass::label()} record", - 'schema' => $repositoryClass::updateToolSchema($schema), - ]; - } - - protected function getDeleteOperationDetails(Repository $repository, string $repositoryClass): array - { - 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"); - } - - 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; - - 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'), - ], - ]; - } - - protected function getActionOperationDetails(Repository $repository, string $repositoryClass, ?string $actionName): array - { - 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); - - if (! $action) { - throw new \InvalidArgumentException("Action '{$actionName}' not found in repository '{$repository::uriKey()}'"); - } - - $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), - ]; - } - - 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 - { - 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); - - return Response::json($result); - } - - protected function executeShowOperation(Repository $repository, 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); - - return Response::json($result); - } - - protected function executeStoreOperation(Repository $repository, 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); - - $result = $repository->storeTool($request); - - return Response::json($result); - } - - protected function executeUpdateOperation(Repository $repository, array $parameters): Response - { - if (! method_exists($repository, 'mcpAllowsUpdate') || ! $repository->mcpAllowsUpdate()) { - throw new \InvalidArgumentException("Repository '{$repository::uriKey()}' does not allow update operation"); - } - - $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); - } - - protected function executeDeleteOperation(Repository $repository, 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) {} - - public function parameter($key, $default = null) - { - return $key === 'repositoryId' ? $this->id : $default; - } - }; - }); - } - - $result = $repository->deleteTool($request); - - return Response::json($result); - } - - protected function executeProfileOperation(Repository $repository, array $parameters): Response - { - $tool = new ProfileTool(get_class($repository)); - $request = app(McpRequest::class); - $request->replace($parameters); - - return $tool->handle($request); - } - - protected function executeActionOperation(Repository $repository, string $repositoryClass, ?string $actionName, 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); - - 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); - } - - protected function executeGetterOperation(Repository $repository, string $repositoryClass, ?string $getterName, 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()}'"); - } - - $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); - } -} 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 = []; 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