From 17c57ffeb6c7fa57fb687fd727711cdf142529e9 Mon Sep 17 00:00:00 2001 From: Eduard Lupacescu Date: Wed, 22 Oct 2025 17:41:32 +0300 Subject: [PATCH 1/3] feat: add MCP wrapper tool system for progressive repository discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement a 4-tool wrapper system (inspired by Klavis MCP) that reduces MCP tool explosion by wrapping repository operations through progressive discovery. **New Features:** - Add configurable MCP mode (`direct` or `wrapper`) via `RESTIFY_MCP_MODE` env variable - Create 4 wrapper tools for progressive discovery: - `discover-repositories`: List all MCP-enabled repositories - `get-repository-operations`: Get operations for a specific repository - `get-operation-details`: Get detailed schema for an operation - `execute-operation`: Execute an operation with parameters **Architecture:** - `ToolRegistry` service: Centralized registry for repository/operation metadata with caching - `WrapperToolHelpers` trait: Shared utilities for schema formatting and example generation - Multi-layer validation ensures only MCP-enabled repositories are accessible **Benefits:** - Reduces tool count from 50+ to 4 wrapper tools (in wrapper mode) - Better token efficiency for AI agents - Progressive discovery improves exploration - Backward compatible via config (defaults to `direct` mode) - Static tools remain unaffected regardless of mode **Validation:** - Only repositories with `HasMcpTools` trait are exposed - Each operation validates `mcpAllows*()` permissions - Clear error messages guide missing configuration 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- config/restify.php | 21 + src/MCP/Concerns/WrapperToolHelpers.php | 247 ++++++ src/MCP/RestifyServer.php | 19 + src/MCP/Services/ToolRegistry.php | 729 ++++++++++++++++++ .../Wrapper/DiscoverRepositoriesTool.php | 55 ++ .../Tools/Wrapper/ExecuteOperationTool.php | 114 +++ .../Tools/Wrapper/GetOperationDetailsTool.php | 100 +++ .../Wrapper/GetRepositoryOperationsTool.php | 87 +++ 8 files changed, 1372 insertions(+) create mode 100644 src/MCP/Concerns/WrapperToolHelpers.php create mode 100644 src/MCP/Services/ToolRegistry.php create mode 100644 src/MCP/Tools/Wrapper/DiscoverRepositoriesTool.php create mode 100644 src/MCP/Tools/Wrapper/ExecuteOperationTool.php create mode 100644 src/MCP/Tools/Wrapper/GetOperationDetailsTool.php create mode 100644 src/MCP/Tools/Wrapper/GetRepositoryOperationsTool.php diff --git a/config/restify.php b/config/restify.php index 83d0c8fc..0292fc62 100644 --- a/config/restify.php +++ b/config/restify.php @@ -284,6 +284,27 @@ | */ 'mcp' => [ + /* + |-------------------------------------------------------------------------- + | MCP Mode + |-------------------------------------------------------------------------- + | + | This setting controls how repository operations are exposed to MCP clients. + | + | - 'direct': Each repository operation (index, show, store, etc.) is + | registered as a separate MCP tool. This provides immediate access to + | all operations but can result in many tools. + | + | - 'wrapper': Repository operations are accessed through 4 wrapper tools + | (discover, get operations, get details, execute). This reduces tool + | count and provides progressive discovery but requires multiple calls. + | + | Static tools (like GlobalSearchTool) are always registered directly + | regardless of this setting. + | + */ + 'mode' => env('RESTIFY_MCP_MODE', 'direct'), + 'tools' => [ 'exclude' => [ // Tool classes to exclude from discovery diff --git a/src/MCP/Concerns/WrapperToolHelpers.php b/src/MCP/Concerns/WrapperToolHelpers.php new file mode 100644 index 00000000..6df33088 --- /dev/null +++ b/src/MCP/Concerns/WrapperToolHelpers.php @@ -0,0 +1,247 @@ + $value) { + if (is_object($value) && method_exists($value, 'toArray')) { + $formatted[$key] = $value->toArray(); + } else { + $formatted[$key] = $value; + } + } + + return $formatted; + } + + /** + * Generate examples from operation schema. + */ + protected function generateExamplesFromSchema(array $schema, string $operationType): array + { + $examples = []; + + switch ($operationType) { + case 'index': + $examples[] = [ + 'description' => 'Basic pagination', + 'parameters' => [ + 'page' => 1, + 'perPage' => 15, + ], + ]; + + if (isset($schema['search'])) { + $examples[] = [ + 'description' => 'Search with pagination', + 'parameters' => [ + 'search' => 'example search term', + 'page' => 1, + 'perPage' => 15, + ], + ]; + } + + if (isset($schema['include'])) { + $examples[] = [ + 'description' => 'With relationships', + 'parameters' => [ + 'page' => 1, + 'perPage' => 15, + 'include' => 'posts,comments', + ], + ]; + } + break; + + case 'show': + $examples[] = [ + 'description' => 'Show single record', + 'parameters' => [ + 'id' => '1', + ], + ]; + + if (isset($schema['include'])) { + $examples[] = [ + 'description' => 'Show with relationships', + 'parameters' => [ + 'id' => '1', + 'include' => 'posts,comments', + ], + ]; + } + break; + + case 'store': + $exampleParams = []; + foreach ($schema as $key => $field) { + if ($key === 'include') { + continue; + } + + $exampleParams[$key] = $this->generateExampleValue($key, $field); + } + + if (! empty($exampleParams)) { + $examples[] = [ + 'description' => 'Create new record', + 'parameters' => $exampleParams, + ]; + } + break; + + case 'update': + $exampleParams = ['id' => '1']; + foreach ($schema as $key => $field) { + if (in_array($key, ['id', 'include'])) { + continue; + } + + $exampleParams[$key] = $this->generateExampleValue($key, $field); + } + + if (count($exampleParams) > 1) { + $examples[] = [ + 'description' => 'Update existing record', + 'parameters' => $exampleParams, + ]; + } + break; + + case 'delete': + $examples[] = [ + 'description' => 'Delete a record', + 'parameters' => [ + 'id' => '1', + ], + ]; + break; + } + + return $examples; + } + + /** + * Generate example value based on field name and type. + */ + protected function generateExampleValue(string $fieldName, $fieldSchema): mixed + { + if (is_object($fieldSchema) && method_exists($fieldSchema, 'toArray')) { + $fieldArray = $fieldSchema->toArray(); + $type = $fieldArray['type'] ?? 'string'; + } elseif (is_array($fieldSchema)) { + $type = $fieldSchema['type'] ?? 'string'; + } else { + $type = 'string'; + } + + return match ($type) { + 'boolean' => true, + 'number', 'integer' => $this->generateNumberExample($fieldName), + 'array' => [], + default => $this->generateStringExample($fieldName), + }; + } + + /** + * Generate number example based on field name. + */ + protected function generateNumberExample(string $fieldName): int|float + { + $fieldName = strtolower($fieldName); + + if (str_contains($fieldName, 'price') || str_contains($fieldName, 'amount')) { + return 99.99; + } + + if (str_contains($fieldName, 'age')) { + return 25; + } + + if (str_contains($fieldName, 'year')) { + return 2024; + } + + if (str_ends_with($fieldName, '_id')) { + return 1; + } + + return 1; + } + + /** + * Generate string example based on field name. + */ + protected function generateStringExample(string $fieldName): string + { + $fieldName = strtolower($fieldName); + + if (str_contains($fieldName, 'email')) { + return 'user@example.com'; + } + + if (str_contains($fieldName, 'name')) { + return 'Example Name'; + } + + if (str_contains($fieldName, 'title')) { + return 'Example Title'; + } + + if (str_contains($fieldName, 'description')) { + return 'Example description'; + } + + if (str_contains($fieldName, 'url')) { + return 'https://example.com'; + } + + if (str_contains($fieldName, 'phone')) { + return '+1234567890'; + } + + return 'example value'; + } + + /** + * Build error response. + */ + protected function buildErrorResponse(string $message, ?string $code = null): array + { + $response = [ + 'error' => $message, + ]; + + if ($code) { + $response['code'] = $code; + } + + return $response; + } + + /** + * Build success response. + */ + protected function buildSuccessResponse(array $data, ?string $message = null): array + { + $response = [ + 'success' => true, + 'data' => $data, + ]; + + if ($message) { + $response['message'] = $message; + } + + return $response; + } +} diff --git a/src/MCP/RestifyServer.php b/src/MCP/RestifyServer.php index f65ddd30..448d0c86 100644 --- a/src/MCP/RestifyServer.php +++ b/src/MCP/RestifyServer.php @@ -125,6 +125,14 @@ protected function discoverTools(): array protected function discoverRepositoryTools(): void { + // Check if we should use wrapper mode + if (config('restify.mcp.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)); @@ -167,6 +175,17 @@ protected function discoverRepositoryTools(): void }); } + /** + * 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); diff --git a/src/MCP/Services/ToolRegistry.php b/src/MCP/Services/ToolRegistry.php new file mode 100644 index 00000000..76f76d69 --- /dev/null +++ b/src/MCP/Services/ToolRegistry.php @@ -0,0 +1,729 @@ +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 new file mode 100644 index 00000000..1cdaef3c --- /dev/null +++ b/src/MCP/Tools/Wrapper/DiscoverRepositoriesTool.php @@ -0,0 +1,55 @@ + $schema->string() + ->description('Optional search term to filter repositories by name, label, or description. Case-insensitive partial matching.'), + ]; + } + + public function handle(Request $request): Response + { + try { + $registry = app(ToolRegistry::class); + $search = $request->get('search'); + + $repositories = $registry->getAvailableRepositories($search); + + return Response::json([ + 'success' => true, + 'total' => $repositories->count(), + 'repositories' => $repositories->toArray(), + 'next_steps' => [ + 'To see detailed operations for a repository, use the "get-repository-operations" tool with the repository name', + 'Example: get-repository-operations with repository="users"', + ], + ]); + } catch (\Exception $e) { + return Response::json($this->buildErrorResponse($e->getMessage(), 'DISCOVERY_ERROR')); + } + } +} diff --git a/src/MCP/Tools/Wrapper/ExecuteOperationTool.php b/src/MCP/Tools/Wrapper/ExecuteOperationTool.php new file mode 100644 index 00000000..4eb15aac --- /dev/null +++ b/src/MCP/Tools/Wrapper/ExecuteOperationTool.php @@ -0,0 +1,114 @@ + $schema->string() + ->description('The repository URI key (e.g., "users", "posts")') + ->required(), + + 'operation_type' => $schema->string() + ->description('The type of operation to execute: "index", "show", "store", "update", "delete", "profile", "action", or "getter"') + ->required(), + + 'operation_name' => $schema->string() + ->description('Required only for "action" and "getter" operation types. The URI key of the specific action or getter to execute.'), + + 'parameters' => $schema->object() + ->description('The parameters to pass to the operation. The required parameters depend on the operation type. Use get-operation-details to see the schema for the specific operation.') + ->required(), + ]; + } + + 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'); + $parameters = $request->get('parameters', []); + + if (! $repositoryKey) { + return Response::json($this->buildErrorResponse( + 'Repository parameter is required', + 'MISSING_PARAMETER' + )); + } + + if (! $operationType) { + return Response::json($this->buildErrorResponse( + 'Operation type parameter is required', + 'MISSING_PARAMETER' + )); + } + + if (in_array($operationType, ['action', 'getter']) && ! $operationName) { + return Response::json($this->buildErrorResponse( + "Operation name is required for {$operationType} operation type", + 'MISSING_PARAMETER' + )); + } + + if (! is_array($parameters)) { + return Response::json($this->buildErrorResponse( + 'Parameters must be an object/array', + 'INVALID_PARAMETERS' + )); + } + + // Execute the operation through the registry + $result = $registry->executeOperation($repositoryKey, $operationType, $operationName, $parameters); + + return $result; + } catch (\InvalidArgumentException $e) { + return Response::json($this->buildErrorResponse($e->getMessage(), 'INVALID_OPERATION')); + } catch (\Illuminate\Validation\ValidationException $e) { + return Response::json([ + 'error' => 'Validation failed', + 'code' => 'VALIDATION_ERROR', + 'errors' => $e->errors(), + ]); + } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + return Response::json($this->buildErrorResponse( + 'Not authorized to perform this operation', + 'AUTHORIZATION_ERROR' + )); + } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) { + return Response::json($this->buildErrorResponse( + 'Record not found', + 'NOT_FOUND' + )); + } catch (\Exception $e) { + return Response::json([ + 'error' => $e->getMessage(), + 'code' => 'EXECUTION_ERROR', + 'type' => get_class($e), + ]); + } + } +} diff --git a/src/MCP/Tools/Wrapper/GetOperationDetailsTool.php b/src/MCP/Tools/Wrapper/GetOperationDetailsTool.php new file mode 100644 index 00000000..6ba91e1e --- /dev/null +++ b/src/MCP/Tools/Wrapper/GetOperationDetailsTool.php @@ -0,0 +1,100 @@ + $schema->string() + ->description('The repository URI key (e.g., "users", "posts")') + ->required(), + + 'operation_type' => $schema->string() + ->description('The type of operation: "index" (list records), "show" (get single record), "store" (create), "update" (modify), "delete" (remove), "profile" (get authenticated user), "action" (custom action), or "getter" (custom getter)') + ->required(), + + 'operation_name' => $schema->string() + ->description('Required only for "action" and "getter" operation types. The URI key of the specific action or getter to get details for.'), + ]; + } + + 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'); + + if (! $repositoryKey) { + return Response::json($this->buildErrorResponse( + 'Repository parameter is required', + 'MISSING_PARAMETER' + )); + } + + if (! $operationType) { + return Response::json($this->buildErrorResponse( + 'Operation type parameter is required', + 'MISSING_PARAMETER' + )); + } + + if (in_array($operationType, ['action', 'getter']) && ! $operationName) { + return Response::json($this->buildErrorResponse( + "Operation name is required for {$operationType} operation type", + 'MISSING_PARAMETER' + )); + } + + $details = $registry->getOperationDetails($repositoryKey, $operationType, $operationName); + + // Format schema for better readability + $formattedSchema = $this->formatSchemaForDisplay($details['schema']); + + // Generate examples + $examples = $this->generateExamplesFromSchema($formattedSchema, $operationType); + + return Response::json([ + 'success' => true, + 'operation' => $details['operation'], + 'type' => $details['type'], + 'title' => $details['title'], + 'description' => $details['description'], + 'schema' => $formattedSchema, + 'examples' => $examples, + 'next_steps' => [ + 'To execute this operation, use the "execute-operation" tool with the same repository and operation_type', + 'Provide the required parameters according to the schema above', + 'Example: execute-operation with repository="'.$repositoryKey.'", operation_type="'.$operationType.'"'.($operationName ? ', operation_name="'.$operationName.'"' : ''), + ], + ]); + } catch (\InvalidArgumentException $e) { + return Response::json($this->buildErrorResponse($e->getMessage(), 'INVALID_OPERATION')); + } catch (\Exception $e) { + return Response::json($this->buildErrorResponse($e->getMessage(), 'OPERATION_DETAILS_ERROR')); + } + } +} diff --git a/src/MCP/Tools/Wrapper/GetRepositoryOperationsTool.php b/src/MCP/Tools/Wrapper/GetRepositoryOperationsTool.php new file mode 100644 index 00000000..d01af132 --- /dev/null +++ b/src/MCP/Tools/Wrapper/GetRepositoryOperationsTool.php @@ -0,0 +1,87 @@ + $schema->string() + ->description('The repository URI key (e.g., "users", "posts"). Use discover-repositories to see available repositories.') + ->required(), + ]; + } + + public function handle(Request $request): Response + { + try { + $registry = app(ToolRegistry::class); + $repositoryKey = $request->get('repository'); + + if (! $repositoryKey) { + return Response::json($this->buildErrorResponse( + 'Repository parameter is required', + 'MISSING_PARAMETER' + )); + } + + $operations = $registry->getRepositoryOperations($repositoryKey); + + $nextSteps = []; + + if (! empty($operations['operations'])) { + $nextSteps[] = 'To see detailed schema for a CRUD operation, use "get-operation-details" tool'; + $nextSteps[] = 'Example: get-operation-details with repository="'.$repositoryKey.'", operation_type="index"'; + } + + if (! empty($operations['actions'])) { + $nextSteps[] = 'For action details, use "get-operation-details" with operation_type="action" and operation_name'; + $nextSteps[] = 'Example: get-operation-details with repository="'.$repositoryKey.'", operation_type="action", operation_name="'.$operations['actions'][0]['name'].'"'; + } + + if (! empty($operations['getters'])) { + $nextSteps[] = 'For getter details, use "get-operation-details" with operation_type="getter" and operation_name'; + } + + return Response::json([ + 'success' => true, + 'repository' => $operations['repository'], + 'label' => $operations['label'], + 'description' => $operations['description'], + 'operations' => $operations['operations'], + 'actions' => $operations['actions'], + 'getters' => $operations['getters'], + 'summary' => [ + 'crud_operations_count' => count($operations['operations']), + 'actions_count' => count($operations['actions']), + 'getters_count' => count($operations['getters']), + ], + 'next_steps' => $nextSteps, + ]); + } catch (\InvalidArgumentException $e) { + return Response::json($this->buildErrorResponse($e->getMessage(), 'INVALID_REPOSITORY')); + } catch (\Exception $e) { + return Response::json($this->buildErrorResponse($e->getMessage(), 'OPERATION_LISTING_ERROR')); + } + } +} From bab0a1b01575cc2e546c42a41f657322fcf3e8e6 Mon Sep 17 00:00:00 2001 From: Eduard Lupacescu Date: Wed, 22 Oct 2025 17:47:09 +0300 Subject: [PATCH 2/3] test: add comprehensive integration tests for MCP wrapper tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add WrapperToolsIntegrationTest covering all 4 wrapper tools and their functionality. **Tests Added:** - Wrapper mode exposes correct tools (4 wrapper + static tools) - discover-repositories: Lists only MCP-enabled repositories - discover-repositories: Search functionality works correctly - get-repository-operations: Returns complete operations list - get-repository-operations: Rejects non-MCP repositories with clear error - get-operation-details: Returns schema with examples - execute-operation: Creates records via wrapper (store operation) - execute-operation: Lists records via wrapper (index operation) - execute-operation: Validates operation permissions - Complete workflow: Tests entire discover → operations → details → execute flow **Coverage:** - 10 test methods - 74 assertions - Tests all 4 wrapper tools - Tests error handling and validation - Tests end-to-end workflow 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- tests/MCP/WrapperToolsIntegrationTest.php | 758 ++++++++++++++++++++++ 1 file changed, 758 insertions(+) create mode 100644 tests/MCP/WrapperToolsIntegrationTest.php diff --git a/tests/MCP/WrapperToolsIntegrationTest.php b/tests/MCP/WrapperToolsIntegrationTest.php new file mode 100644 index 00000000..d6782756 --- /dev/null +++ b/tests/MCP/WrapperToolsIntegrationTest.php @@ -0,0 +1,758 @@ + true]); + + // Enable wrapper mode + config(['restify.mcp.mode' => 'wrapper']); + + // Clear any cached repository data between tests + \Illuminate\Support\Facades\Cache::flush(); + + // Reset Restify repositories to prevent cross-test contamination + Restify::$repositories = []; + } + + protected function getPackageProviders($app): array + { + return array_merge(parent::getPackageProviders($app), [ + McpServiceProvider::class, + ]); + } + + public function test_wrapper_mode_exposes_only_4_wrapper_tools(): void + { + // Create test repositories + $mcpRepository = new class extends Repository + { + use HasMcpTools; + + public static $model = Post::class; + + public static string $uriKey = 'test-posts'; + + public function fields(RestifyRequest $request): array + { + return [ + Field::make('title'), + ]; + } + + public function mcpAllowsIndex(): bool + { + return true; + } + + public function mcpAllowsStore(): bool + { + return true; + } + }; + + // Register the repository with Restify + Restify::repositories([ + $mcpRepository::class, + ]); + + // Register MCP server route + Mcp::web('test-wrapper-restify', RestifyServer::class); + + // Get the list of available tools + $toolsListPayload = [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/list', + 'params' => [], + ]; + + $this->withoutExceptionHandling(); + $toolsResponse = $this->postJson('/test-wrapper-restify', $toolsListPayload); + $toolsResponse->assertOk(); + + $toolsData = $toolsResponse->json(); + $tools = collect($toolsData['result']['tools']); + + // Assert the 4 wrapper tools exist + $toolNames = $tools->pluck('name')->toArray(); + $this->assertContains('discover-repositories', $toolNames); + $this->assertContains('get-repository-operations', $toolNames); + $this->assertContains('get-operation-details', $toolNames); + $this->assertContains('execute-operation', $toolNames); + + // Assert no direct repository tools are exposed (repository operations should be wrapped) + $this->assertNotContains('test-posts-index-tool', $toolNames); + $this->assertNotContains('test-posts-store-tool', $toolNames); + + // Note: Static tools (like global-search) are always exposed regardless of mode + // This is expected behavior - we only wrap repository-based operations + } + + public function test_discover_repositories_tool_lists_mcp_enabled_repositories(): void + { + // Create test repositories - one with MCP, one without + $mcpEnabledRepository = new class extends Repository + { + use HasMcpTools; + + public static $model = Post::class; + + public static string $uriKey = 'mcp-enabled-posts'; + + public function fields(RestifyRequest $request): array + { + return [Field::make('title')]; + } + + public function mcpAllowsIndex(): bool + { + return true; + } + }; + + $nonMcpRepository = new class extends Repository + { + public static $model = Post::class; + + public static string $uriKey = 'non-mcp-posts'; + + public function fields(RestifyRequest $request): array + { + return [Field::make('title')]; + } + }; + + // Register both repositories + Restify::repositories([ + $mcpEnabledRepository::class, + $nonMcpRepository::class, + ]); + + Mcp::web('test-discover-restify', RestifyServer::class); + + // Call discover-repositories tool + $mcpPayload = [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'discover-repositories', + 'arguments' => [], + ], + ]; + + $response = $this->postJson('/test-discover-restify', $mcpPayload); + $response->assertOk(); + + $responseData = $response->json(); + $this->assertArrayHasKey('result', $responseData); + + $resultContent = json_decode($responseData['result']['content'][0]['text'], true); + + // Assert success + $this->assertTrue($resultContent['success']); + $this->assertArrayHasKey('repositories', $resultContent); + + $repositories = collect($resultContent['repositories']); + + // Assert only MCP-enabled repository is returned + $this->assertCount(1, $repositories); + $this->assertEquals('mcp-enabled-posts', $repositories->first()['name']); + + // Assert non-MCP repository is not included + $this->assertFalse($repositories->contains('name', 'non-mcp-posts')); + + // Assert metadata is included + $repo = $repositories->first(); + $this->assertArrayHasKey('label', $repo); + $this->assertArrayHasKey('operations', $repo); + $this->assertContains('index', $repo['operations']); + } + + public function test_discover_repositories_tool_supports_search(): void + { + Restify::$repositories = []; // Clear any previous repos + + $postRepo = new class extends Repository + { + use HasMcpTools; + + public static $model = Post::class; + + public static string $uriKey = 'posts'; + + public function fields(RestifyRequest $request): array + { + return [Field::make('title')]; + } + }; + + $userRepo = new class extends Repository + { + use HasMcpTools; + + public static $model = Post::class; + + public static string $uriKey = 'users'; + + public function fields(RestifyRequest $request): array + { + return [Field::make('name')]; + } + }; + + Restify::repositories([$postRepo::class, $userRepo::class]); + Mcp::web('test-search-restify', RestifyServer::class); + + // Test 1: Search for "user" should find users repository + $mcpPayload = [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'discover-repositories', + 'arguments' => [ + 'search' => 'users', + ], + ], + ]; + + $response = $this->postJson('/test-search-restify', $mcpPayload); + $response->assertOk(); + + $resultContent = json_decode($response->json()['result']['content'][0]['text'], true); + $repositories = collect($resultContent['repositories']); + + // Should include users repository (search is case-insensitive) + $userRepo = $repositories->firstWhere('name', 'users'); + $this->assertNotNull($userRepo, 'Users repository should be found'); + + // Test 2: Search for "posts" should find posts repository + $mcpPayload2 = [ + 'jsonrpc' => '2.0', + 'id' => 2, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'discover-repositories', + 'arguments' => [ + 'search' => 'posts', + ], + ], + ]; + + $response2 = $this->postJson('/test-search-restify', $mcpPayload2); + $response2->assertOk(); + + $resultContent2 = json_decode($response2->json()['result']['content'][0]['text'], true); + $repositories2 = collect($resultContent2['repositories']); + + // Should include posts repository + $postsRepo = $repositories2->firstWhere('name', 'posts'); + $this->assertNotNull($postsRepo, 'Posts repository should be found'); + } + + public function test_get_repository_operations_tool_returns_operations_list(): void + { + $mcpRepository = new class extends Repository + { + use HasMcpTools; + + public static $model = Post::class; + + public static string $uriKey = 'test-operations-posts'; + + public function fields(RestifyRequest $request): array + { + return [Field::make('title')]; + } + + public function mcpAllowsIndex(): bool + { + return true; + } + + public function mcpAllowsStore(): bool + { + return true; + } + + public function mcpAllowsUpdate(): bool + { + return true; + } + }; + + Restify::repositories([$mcpRepository::class]); + Mcp::web('test-ops-restify', RestifyServer::class); + + $mcpPayload = [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'get-repository-operations', + 'arguments' => [ + 'repository' => 'test-operations-posts', + ], + ], + ]; + + $response = $this->postJson('/test-ops-restify', $mcpPayload); + $response->assertOk(); + + $resultContent = json_decode($response->json()['result']['content'][0]['text'], true); + + // Assert structure + $this->assertTrue($resultContent['success']); + $this->assertEquals('test-operations-posts', $resultContent['repository']); + $this->assertArrayHasKey('operations', $resultContent); + $this->assertArrayHasKey('summary', $resultContent); + + // Assert operations + $operations = collect($resultContent['operations']); + $this->assertCount(3, $operations); // index, store, update + + $operationTypes = $operations->pluck('type')->toArray(); + $this->assertContains('index', $operationTypes); + $this->assertContains('store', $operationTypes); + $this->assertContains('update', $operationTypes); + + // Assert summary + $this->assertEquals(3, $resultContent['summary']['crud_operations_count']); + } + + public function test_get_repository_operations_rejects_non_mcp_repositories(): void + { + $nonMcpRepository = new class extends Repository + { + public static $model = Post::class; + + public static string $uriKey = 'non-mcp-repo'; + + public function fields(RestifyRequest $request): array + { + return [Field::make('title')]; + } + }; + + Restify::repositories([$nonMcpRepository::class]); + Mcp::web('test-reject-restify', RestifyServer::class); + + $mcpPayload = [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'get-repository-operations', + 'arguments' => [ + 'repository' => 'non-mcp-repo', + ], + ], + ]; + + $response = $this->postJson('/test-reject-restify', $mcpPayload); + $response->assertOk(); + + $resultContent = json_decode($response->json()['result']['content'][0]['text'], true); + + // Should return error + $this->assertArrayHasKey('error', $resultContent); + $this->assertStringContainsString('does not have MCP tools enabled', $resultContent['error']); + $this->assertEquals('INVALID_REPOSITORY', $resultContent['code']); + } + + public function test_get_operation_details_returns_schema_and_examples(): void + { + $mcpRepository = new class extends Repository + { + use HasMcpTools; + + public static $model = Post::class; + + public static string $uriKey = 'test-details-posts'; + + public function fields(RestifyRequest $request): array + { + return [ + Field::make('title')->required(), + Field::make('description'), + ]; + } + + public function mcpAllowsIndex(): bool + { + return true; + } + }; + + Restify::repositories([$mcpRepository::class]); + Mcp::web('test-details-restify', RestifyServer::class); + + $mcpPayload = [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'get-operation-details', + 'arguments' => [ + 'repository' => 'test-details-posts', + 'operation_type' => 'index', + ], + ], + ]; + + $response = $this->postJson('/test-details-restify', $mcpPayload); + $response->assertOk(); + + $resultContent = json_decode($response->json()['result']['content'][0]['text'], true); + + // Assert structure + $this->assertTrue($resultContent['success']); + $this->assertEquals('index', $resultContent['type']); + $this->assertArrayHasKey('schema', $resultContent); + $this->assertArrayHasKey('examples', $resultContent); + + // Assert schema contains expected fields + $schema = $resultContent['schema']; + $this->assertArrayHasKey('page', $schema); + $this->assertArrayHasKey('perPage', $schema); + $this->assertArrayHasKey('search', $schema); + $this->assertArrayHasKey('include', $schema); + + // Assert examples are provided + $this->assertNotEmpty($resultContent['examples']); + $this->assertArrayHasKey('description', $resultContent['examples'][0]); + $this->assertArrayHasKey('parameters', $resultContent['examples'][0]); + } + + public function test_execute_operation_creates_record_via_wrapper(): void + { + $mcpRepository = new class extends Repository + { + use HasMcpTools; + + public static $model = Post::class; + + public static string $uriKey = 'test-execute-posts'; + + public function fields(RestifyRequest $request): array + { + return [ + Field::make('title'), + Field::make('description'), + Field::make('user_id'), + ]; + } + + public function mcpAllowsStore(): bool + { + return true; + } + }; + + Restify::repositories([$mcpRepository::class]); + Mcp::web('test-execute-restify', RestifyServer::class); + + $mcpPayload = [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'execute-operation', + 'arguments' => [ + 'repository' => 'test-execute-posts', + 'operation_type' => 'store', + 'parameters' => [ + 'title' => 'Created via Wrapper', + 'description' => 'This was created through the execute-operation wrapper tool', + 'user_id' => 1, + ], + ], + ], + ]; + + $response = $this->postJson('/test-execute-restify', $mcpPayload); + $response->assertOk(); + + $responseData = $response->json(); + $resultContent = json_decode($responseData['result']['content'][0]['text'], true); + + // Assert successful creation + $this->assertArrayHasKey('data', $resultContent); + $this->assertArrayHasKey('attributes', $resultContent['data']); + + $attributes = $resultContent['data']['attributes']; + $this->assertEquals('Created via Wrapper', $attributes['title']); + $this->assertEquals('This was created through the execute-operation wrapper tool', $attributes['description']); + + // Assert database record + $this->assertDatabaseHas('posts', [ + 'title' => 'Created via Wrapper', + 'description' => 'This was created through the execute-operation wrapper tool', + 'user_id' => 1, + ]); + } + + public function test_execute_operation_lists_records_via_wrapper(): void + { + $mcpRepository = new class extends Repository + { + use HasMcpTools; + + public static $model = Post::class; + + public static string $uriKey = 'test-index-posts'; + + public function fields(RestifyRequest $request): array + { + return [ + Field::make('title'), + Field::make('description'), + ]; + } + + public function mcpAllowsIndex(): bool + { + return true; + } + }; + + Restify::repositories([$mcpRepository::class]); + Mcp::web('test-index-restify', RestifyServer::class); + + // Create test data + PostFactory::many(3); + + $mcpPayload = [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'execute-operation', + 'arguments' => [ + 'repository' => 'test-index-posts', + 'operation_type' => 'index', + 'parameters' => [ + 'page' => 1, + 'perPage' => 10, + ], + ], + ], + ]; + + $response = $this->postJson('/test-index-restify', $mcpPayload); + $response->assertOk(); + + $resultContent = json_decode($response->json()['result']['content'][0]['text'], true); + + // Assert paginated response + $this->assertArrayHasKey('data', $resultContent); + $this->assertArrayHasKey('meta', $resultContent); + $this->assertCount(3, $resultContent['data']); + $this->assertEquals(3, $resultContent['meta']['total']); + } + + public function test_execute_operation_validates_permissions(): void + { + $mcpRepository = new class extends Repository + { + use HasMcpTools; + + public static $model = Post::class; + + public static string $uriKey = 'test-permissions-posts'; + + public function fields(RestifyRequest $request): array + { + return [Field::make('title')]; + } + + public function mcpAllowsIndex(): bool + { + return true; + } + + // Store is NOT allowed + public function mcpAllowsStore(): bool + { + return false; + } + }; + + Restify::repositories([$mcpRepository::class]); + Mcp::web('test-permissions-restify', RestifyServer::class); + + $mcpPayload = [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'execute-operation', + 'arguments' => [ + 'repository' => 'test-permissions-posts', + 'operation_type' => 'store', + 'parameters' => [ + 'title' => 'Should Not Be Created', + ], + ], + ], + ]; + + $response = $this->postJson('/test-permissions-restify', $mcpPayload); + $response->assertOk(); + + $resultContent = json_decode($response->json()['result']['content'][0]['text'], true); + + // Should return error about not allowing store + $this->assertArrayHasKey('error', $resultContent); + $this->assertStringContainsString('does not allow store operation', $resultContent['error']); + } + + public function test_wrapper_mode_complete_workflow(): void + { + // This test demonstrates the complete workflow: discover -> operations -> details -> execute + + $mcpRepository = new class extends Repository + { + use HasMcpTools; + + public static $model = Post::class; + + public static string $uriKey = 'workflow-posts'; + + public function fields(RestifyRequest $request): array + { + return [ + Field::make('title')->required(), + Field::make('description'), + ]; + } + + public function mcpAllowsIndex(): bool + { + return true; + } + + public function mcpAllowsStore(): bool + { + return true; + } + }; + + Restify::repositories([$mcpRepository::class]); + Mcp::web('test-workflow-restify', RestifyServer::class); + + // Step 1: Discover repositories + $discoverPayload = [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'discover-repositories', + 'arguments' => [], + ], + ]; + + $discoverResponse = $this->postJson('/test-workflow-restify', $discoverPayload); + $discoverResponse->assertOk(); + $discoverResult = json_decode($discoverResponse->json()['result']['content'][0]['text'], true); + + $this->assertArrayHasKey('repositories', $discoverResult); + $repositoryName = $discoverResult['repositories'][0]['name']; + $this->assertEquals('workflow-posts', $repositoryName); + + // Step 2: Get operations for the repository + $operationsPayload = [ + 'jsonrpc' => '2.0', + 'id' => 2, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'get-repository-operations', + 'arguments' => [ + 'repository' => $repositoryName, + ], + ], + ]; + + $operationsResponse = $this->postJson('/test-workflow-restify', $operationsPayload); + $operationsResponse->assertOk(); + $operationsResult = json_decode($operationsResponse->json()['result']['content'][0]['text'], true); + + $this->assertArrayHasKey('operations', $operationsResult); + $operations = collect($operationsResult['operations']); + $this->assertTrue($operations->contains('type', 'store')); + + // Step 3: Get details for store operation + $detailsPayload = [ + 'jsonrpc' => '2.0', + 'id' => 3, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'get-operation-details', + 'arguments' => [ + 'repository' => $repositoryName, + 'operation_type' => 'store', + ], + ], + ]; + + $detailsResponse = $this->postJson('/test-workflow-restify', $detailsPayload); + $detailsResponse->assertOk(); + $detailsResult = json_decode($detailsResponse->json()['result']['content'][0]['text'], true); + + $this->assertArrayHasKey('schema', $detailsResult); + $this->assertArrayHasKey('title', $detailsResult['schema']); + + // Step 4: Execute the store operation + $executePayload = [ + 'jsonrpc' => '2.0', + 'id' => 4, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'execute-operation', + 'arguments' => [ + 'repository' => $repositoryName, + 'operation_type' => 'store', + 'parameters' => [ + 'title' => 'Complete Workflow Test', + 'description' => 'Created through complete wrapper workflow', + ], + ], + ], + ]; + + $executeResponse = $this->postJson('/test-workflow-restify', $executePayload); + $executeResponse->assertOk(); + $executeResult = json_decode($executeResponse->json()['result']['content'][0]['text'], true); + + $this->assertArrayHasKey('data', $executeResult); + $this->assertEquals('Complete Workflow Test', $executeResult['data']['attributes']['title']); + + // Verify in database + $this->assertDatabaseHas('posts', [ + 'title' => 'Complete Workflow Test', + 'description' => 'Created through complete wrapper workflow', + ]); + } +} From 70c2ee07455adc0580227ec2828b34821232a45b Mon Sep 17 00:00:00 2001 From: Eduard Lupacescu Date: Wed, 22 Oct 2025 17:53:20 +0300 Subject: [PATCH 3/3] feat: nuno --- docs-v3/content/docs/mcp/mcp.md | 225 ++++++++++++++++++++++ tests/MCP/WrapperToolsIntegrationTest.php | 11 +- 2 files changed, 234 insertions(+), 2 deletions(-) diff --git a/docs-v3/content/docs/mcp/mcp.md b/docs-v3/content/docs/mcp/mcp.md index 5f253b5f..199cf1b7 100644 --- a/docs-v3/content/docs/mcp/mcp.md +++ b/docs-v3/content/docs/mcp/mcp.md @@ -180,6 +180,7 @@ The MCP integration respects your existing Restify configuration and adds MCP-sp 'server_name' => 'My App MCP Server', 'server_version' => '1.0.0', 'default_pagination' => 25, + 'mode' => env('RESTIFY_MCP_MODE', 'direct'), // 'direct' or 'wrapper' 'tools' => [ 'exclude' => [ // Tools to exclude from discovery @@ -190,3 +191,227 @@ The MCP integration respects your existing Restify configuration and adds MCP-sp ], ], ``` + +## MCP Mode: Direct vs Wrapper + +Laravel Restify offers two modes for exposing your repositories through MCP: **Direct Mode** and **Wrapper Mode**. Each mode has different trade-offs in terms of token usage and discoverability. + +### Direct Mode (Default) + +In direct mode, every repository operation (index, show, store, update, delete) and custom action/getter is exposed as a separate MCP tool. This provides maximum discoverability for AI agents. + +**When to use Direct Mode:** +- You have a small number of repositories (< 10) +- You want AI agents to instantly see all available operations +- Token usage is not a concern +- You prefer simpler, more straightforward tool discovery + +**Example:** With 10 repositories, each having 5 CRUD operations plus 2 actions, you would expose **70 tools** to the AI agent. + +**Configuration:** +```php +// .env +RESTIFY_MCP_MODE=direct +``` + +### Wrapper Mode (Token-Efficient) + +Wrapper mode uses a progressive discovery pattern that exposes only **4 wrapper tools** regardless of how many repositories you have. AI agents discover and execute operations through a multi-step workflow. + +**When to use Wrapper Mode:** +- You have many repositories (10+) +- Token usage efficiency is important (e.g., working with large context windows) +- You want to reduce the initial tool list size +- You're building complex applications with dozens of repositories + +**Token Savings Example:** +- Direct mode with 50 repositories: ~250+ tools exposed +- Wrapper mode with 50 repositories: **4 tools exposed** +- **Token reduction: ~98% fewer tokens used for tool definitions** + +**Configuration:** +```php +// .env +RESTIFY_MCP_MODE=wrapper +``` + +### The 4 Wrapper Tools + +When using wrapper mode, AI agents use these 4 tools in a progressive discovery workflow: + +#### 1. `discover-repositories` +Lists all available MCP-enabled repositories with metadata. Supports optional search filtering. + +**Example Request:** +```json +{ + "search": "user" +} +``` + +**Example Response:** +```json +{ + "success": true, + "repositories": [ + { + "name": "users", + "title": "Users", + "description": "Manage user accounts", + "operations": ["index", "show", "store", "update", "delete", "profile"], + "actions_count": 2, + "getters_count": 1 + } + ] +} +``` + +#### 2. `get-repository-operations` +Lists all operations, actions, and getters available for a specific repository. + +**Example Request:** +```json +{ + "repository": "users" +} +``` + +**Example Response:** +```json +{ + "success": true, + "repository": "users", + "crud_operations": ["index", "show", "store", "update", "delete", "profile"], + "actions": [ + { + "name": "activate-user", + "title": "Activate User", + "description": "Activate a user account" + } + ], + "getters": [ + { + "name": "active-users", + "title": "Active Users", + "description": "Get all active users" + } + ] +} +``` + +#### 3. `get-operation-details` +Returns the complete JSON schema and documentation for a specific operation, including all parameters, validation rules, and examples. + +**Example Request:** +```json +{ + "repository": "users", + "operation_type": "store" +} +``` + +**Example Response:** +```json +{ + "success": true, + "operation": "store", + "type": "create", + "title": "Create User", + "description": "Create a new user account", + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The user's full name", + "required": true + }, + "email": { + "type": "string", + "description": "The user's email address", + "required": true + } + } + }, + "examples": [ + { + "name": "John Doe", + "email": "john@example.com" + } + ] +} +``` + +#### 4. `execute-operation` +Executes a repository operation with the provided parameters. This is the final step after discovering the repository, listing operations, and getting operation details. + +**Example Request:** +```json +{ + "repository": "users", + "operation_type": "store", + "parameters": { + "name": "John Doe", + "email": "john@example.com" + } +} +``` + +**Example Response:** +```json +{ + "success": true, + "data": { + "id": 123, + "name": "John Doe", + "email": "john@example.com" + } +} +``` + +### Wrapper Mode Workflow + +Here's a typical workflow when an AI agent uses wrapper mode: + +1. **Discover repositories**: Agent calls `discover-repositories` to see what repositories are available +2. **Explore operations**: Agent calls `get-repository-operations` for the target repository +3. **Get schema**: Agent calls `get-operation-details` to understand required parameters +4. **Execute**: Agent calls `execute-operation` with the correct parameters + +This progressive discovery pattern reduces token usage while maintaining full functionality. + +### Switching Between Modes + +You can switch between modes at any time by updating your `.env` file: + +```bash +# Direct mode (default) +RESTIFY_MCP_MODE=direct + +# Wrapper mode (token-efficient) +RESTIFY_MCP_MODE=wrapper +``` + +No code changes are required. The MCP server automatically adapts to the configured mode. + +### Performance Considerations + +**Direct Mode:** +- ✅ Faster initial discovery (all tools visible immediately) +- ❌ Higher token usage (all tools loaded into context) +- ✅ Simpler for AI agents to understand +- ❌ Can overwhelm context window with large applications + +**Wrapper Mode:** +- ✅ Dramatically lower token usage (4 tools vs 100+) +- ✅ Scales well with large applications +- ❌ Requires multi-step workflow +- ✅ Better for applications with many repositories + +### Best Practices + +1. **Start with Direct Mode** during development to verify all tools are working correctly +2. **Switch to Wrapper Mode** in production if you have 10+ repositories or token efficiency is important +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 diff --git a/tests/MCP/WrapperToolsIntegrationTest.php b/tests/MCP/WrapperToolsIntegrationTest.php index d6782756..3dd42f47 100644 --- a/tests/MCP/WrapperToolsIntegrationTest.php +++ b/tests/MCP/WrapperToolsIntegrationTest.php @@ -12,6 +12,7 @@ use Binaryk\LaravelRestify\Tests\Fixtures\Post\Post; use Binaryk\LaravelRestify\Tests\IntegrationTestCase; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Cache; use Laravel\Mcp\Facades\Mcp; use Laravel\Mcp\Server\McpServiceProvider; @@ -28,8 +29,14 @@ protected function setUp(): void // Enable wrapper mode config(['restify.mcp.mode' => 'wrapper']); - // Clear any cached repository data between tests - \Illuminate\Support\Facades\Cache::flush(); + // Mock cache to prevent database cache table errors in tests + Cache::partialMock() + ->shouldReceive('remember') + ->andReturnUsing(function ($key, $ttl, $callback) { + return $callback(); + }) + ->shouldReceive('flush') + ->andReturn(true); // Reset Restify repositories to prevent cross-test contamination Restify::$repositories = [];