diff --git a/README.md b/README.md index 97be9fe..de274a1 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,9 @@ Version 1.4.0 introduces powerful automatic tool and resource generation from Sw - **Swagger/OpenAPI Tool & Resource Generator**: Automatically generate MCP tools or resources from any Swagger/OpenAPI specification - Supports both OpenAPI 3.x and Swagger 2.0 formats - **Choose generation type**: Generate as Tools (for actions) or Resources (for read-only data) - - Interactive endpoint selection with grouping options + - **Multiple grouping strategies** (v1.4.1): Organize by tags, paths, or root directory + - **Enhanced interactive preview** (v1.4.2): Shows directory structure with file counts and examples + - **Interactive endpoint selection** with real-time preview of directory structure - Automatic authentication logic generation (API Key, Bearer Token, OAuth2) - Smart naming for readable class names (handles hash-based operationIds) - Built-in API testing before generation @@ -48,11 +50,17 @@ Version 1.4.0 introduces powerful automatic tool and resource generation from Sw **Example Usage:** ```bash -# Generate tools from OP.GG API +# Generate tools from OP.GG API (interactive mode) php artisan make:swagger-mcp-tool https://api.op.gg/lol/swagger.json -# With options +# With specific grouping php artisan make:swagger-mcp-tool ./api-spec.json --test-api --group-by=tag --prefix=MyApi + +# Group by path segments +php artisan make:swagger-mcp-tool ./api-spec.json --group-by=path + +# No grouping (flat structure) +php artisan make:swagger-mcp-tool ./api-spec.json --group-by=none ``` This feature dramatically reduces the time needed to integrate external APIs into your MCP server! @@ -353,6 +361,86 @@ php artisan make:swagger-mcp-tool https://api.example.com/swagger.json \ --prefix=MyApi ``` +**Grouping Options (v1.4.1+):** + +The generator now supports multiple ways to organize your generated tools and resources into directories: + +```bash +# Tag-based grouping (default) - organize by OpenAPI tags +php artisan make:swagger-mcp-tool petstore.json --group-by=tag +# Creates: Tools/Pet/, Tools/Store/, Tools/User/ + +# Path-based grouping - organize by first path segment +php artisan make:swagger-mcp-tool petstore.json --group-by=path +# Creates: Tools/Api/, Tools/Users/, Tools/Orders/ + +# No grouping - everything in root directories +php artisan make:swagger-mcp-tool petstore.json --group-by=none +# Creates: Tools/, Resources/ +``` + +**Enhanced Interactive Preview (v1.4.2):** + +When you don't specify the `--group-by` option, the command shows a detailed preview with statistics: + +```bash +php artisan make:swagger-mcp-tool petstore.json + +πŸ—‚οΈ Choose how to organize your generated tools and resources: + +Tag-based grouping (organize by OpenAPI tags) +πŸ“Š Total: 25 endpoints β†’ 15 tools + 10 resources + + πŸ“ Pet/ (8 tools, 4 resources) + └─ CreatePetTool.php (POST /pet) + └─ UpdatePetTool.php (PUT /pet) + └─ ... and 10 more files + πŸ“ Store/ (5 tools, 3 resources) + └─ PlaceOrderTool.php (POST /store/order) + └─ GetInventoryResource.php (GET /store/inventory) + └─ ... and 6 more files + πŸ“ User/ (2 tools, 3 resources) + └─ CreateUserTool.php (POST /user) + └─ GetUserByNameResource.php (GET /user/{username}) + └─ ... and 3 more files + +Path-based grouping (organize by API path) +πŸ“Š Total: 25 endpoints β†’ 15 tools + 10 resources + + πŸ“ Pet/ (12 files from /pet) + └─ PostPetTool.php (POST /pet) + └─ GetPetByIdResource.php (GET /pet/{petId}) + └─ ... and 10 more files + πŸ“ Store/ (8 files from /store) + └─ PostStoreOrderTool.php (POST /store/order) + └─ GetStoreInventoryResource.php (GET /store/inventory) + └─ ... and 6 more files + +No grouping (everything in root folder) +πŸ“Š Total: 25 endpoints β†’ 15 tools + 10 resources + + πŸ“ Tools/ (15 files directly in root) + └─ CreatePetTool.php (POST /pet) + └─ UpdatePetTool.php (PUT /pet/{petId}) + └─ ... and 13 more files + πŸ“ Resources/ (10 files directly in root) + └─ GetPetByIdResource.php (GET /pet/{petId}) + └─ GetStoreInventoryResource.php (GET /store/inventory) + └─ ... and 8 more files + +Choose grouping method: + [0] Tag-based grouping + [1] Path-based grouping + [2] No grouping + > 0 +``` + +The interactive preview shows: +- **Total counts**: How many tools and resources will be generated +- **Directory structure**: Actual directories that will be created +- **File examples**: Sample files with their corresponding API endpoints +- **File distribution**: Number of files per directory/group + **Real-world Example with OP.GG API:** ```bash @@ -419,10 +507,14 @@ Generating: LolRegionServerStatsResource - **Resources**: For read-only GET endpoints that provide data - **Smart naming**: Converts paths like `/lol/{region}/server-stats` to `LolRegionServerStatsTool` or `LolRegionServerStatsResource` - **Hash detection**: Automatically detects MD5-like operationIds and uses path-based naming instead -- **Interactive mode**: Select which endpoints to convert +- **Interactive mode**: Select which endpoints to convert with real-time preview - **API testing**: Test API connectivity before generating - **Authentication support**: Automatically generates authentication logic for API Key, Bearer Token, and OAuth2 -- **Smart grouping**: Group endpoints by tags or path prefixes +- **Flexible organization strategies**: + - **Tag-based grouping**: Organize by OpenAPI tags (e.g., `Tools/Pet/`, `Tools/Store/`) + - **Path-based grouping**: Organize by API path segments (e.g., `Tools/Api/`, `Tools/Users/`) + - **Flat structure**: All tools in a single `General/` directory +- **Interactive grouping preview**: See exactly how your files will be organized before generation - **Code generation**: Creates ready-to-use classes with Laravel HTTP client integration The generated tools include: diff --git a/src/Console/Commands/MakeMcpResourceCommand.php b/src/Console/Commands/MakeMcpResourceCommand.php index 5cd707d..2ca7d17 100644 --- a/src/Console/Commands/MakeMcpResourceCommand.php +++ b/src/Console/Commands/MakeMcpResourceCommand.php @@ -71,23 +71,27 @@ public function handle() $this->info("βœ… Created: {$path}"); - $fullClassName = "\\App\\MCP\\Resources\\{$className}"; - - // Ask if they want to automatically register the resource (skip in programmatic mode) - if (! $this->option('programmatic')) { - if ($this->confirm('πŸ€– Would you like to automatically register this resource in config/mcp-server.php?', true)) { - $this->registerResourceInConfig($fullClassName); - } else { - $this->info("β˜‘οΈ Don't forget to register your resource in config/mcp-server.php:"); - $this->comment(' // config/mcp-server.php'); - $this->comment(" 'resources' => ["); - $this->comment(' // other resources...'); - $this->comment(" {$fullClassName}::class,"); - $this->comment(' ],'); - } - } else { - // In programmatic mode, always register the resource + // Build full class name with tag directory support + $tagDirectory = $this->dynamicParams['tagDirectory'] ?? ''; + $fullClassName = '\\App\\MCP\\Resources\\'; + if ($tagDirectory) { + $fullClassName .= "{$tagDirectory}\\"; + } + $fullClassName .= $className; + + // Ask if they want to automatically register the resource + if ($this->option('programmatic') || $this->option('no-interaction')) { + // In programmatic or no-interaction mode, always register automatically + $this->registerResourceInConfig($fullClassName); + } elseif ($this->confirm('πŸ€– Would you like to automatically register this resource in config/mcp-server.php?', true)) { $this->registerResourceInConfig($fullClassName); + } else { + $this->info("β˜‘οΈ Don't forget to register your resource in config/mcp-server.php:"); + $this->comment(' // config/mcp-server.php'); + $this->comment(" 'resources' => ["); + $this->comment(' // other resources...'); + $this->comment(" {$fullClassName}::class,"); + $this->comment(' ],'); } return 0; @@ -136,6 +140,13 @@ protected function getClassName() */ protected function getPath(string $className) { + // Check if we have a tag directory from dynamic params + $tagDirectory = $this->dynamicParams['tagDirectory'] ?? ''; + + if ($tagDirectory) { + return app_path("MCP/Resources/{$tagDirectory}/{$className}.php"); + } + // Create the file in the app/MCP/Resources directory return app_path("MCP/Resources/{$className}.php"); } @@ -188,9 +199,16 @@ protected function buildDynamicClass(string $className): string $mimeType = $this->dynamicParams['mimeType'] ?? 'application/json'; $readLogic = $this->dynamicParams['readLogic'] ?? $this->getDefaultReadLogic(); + // Build namespace with tag directory support + $namespace = 'App\\MCP\\Resources'; + $tagDirectory = $this->dynamicParams['tagDirectory'] ?? ''; + if ($tagDirectory) { + $namespace .= '\\'.$tagDirectory; + } + // Replace placeholders in stub $replacements = [ - '{{ namespace }}' => 'App\\MCP\\Resources', + '{{ namespace }}' => $namespace, '{{ className }}' => $className, '{{ uri }}' => $uri, '{{ name }}' => addslashes($name), @@ -249,9 +267,16 @@ protected function getStubPath() */ protected function replaceStubPlaceholders(string $stub, string $className) { + // Build namespace with tag directory support + $namespace = 'App\\MCP\\Resources'; + $tagDirectory = $this->dynamicParams['tagDirectory'] ?? ''; + if ($tagDirectory) { + $namespace .= '\\'.$tagDirectory; + } + return str_replace( ['{{ className }}', '{{ namespace }}'], - [$className, 'App\\MCP\\Resources'], + [$className, $namespace], $stub ); } @@ -267,7 +292,9 @@ protected function registerResourceInConfig(string $resourceClassName): bool $configPath = config_path('mcp-server.php'); if (! file_exists($configPath)) { - $this->error("❌ Config file not found: {$configPath}"); + if (property_exists($this, 'output') && $this->output) { + $this->error("❌ Config file not found: {$configPath}"); + } return false; } @@ -283,17 +310,23 @@ protected function registerResourceInConfig(string $resourceClassName): bool $newContent = str_replace($toolsArray, "{$toolsArray}{$resourcesArray}", $content); if (file_put_contents($configPath, $newContent)) { - $this->info('βœ… Created resources array and registered resource in config/mcp-server.php'); + if (property_exists($this, 'output') && $this->output) { + $this->info('βœ… Created resources array and registered resource in config/mcp-server.php'); + } return true; } else { - $this->error('❌ Failed to update config file. Please manually register the resource.'); + if (property_exists($this, 'output') && $this->output) { + $this->error('❌ Failed to update config file. Please manually register the resource.'); + } return false; } } - $this->error('❌ Could not locate resources array in config file.'); + if (property_exists($this, 'output') && $this->output) { + $this->error('❌ Could not locate resources array in config file.'); + } return false; } @@ -302,7 +335,9 @@ protected function registerResourceInConfig(string $resourceClassName): bool // Check if the resource is already registered if (strpos($resourcesArrayContent, $resourceClassName) !== false) { - $this->info('βœ… Resource is already registered in config file.'); + if (property_exists($this, 'output') && $this->output) { + $this->info('βœ… Resource is already registered in config file.'); + } return true; } @@ -319,11 +354,15 @@ protected function registerResourceInConfig(string $resourceClassName): bool // Write the updated content back to the config file if (file_put_contents($configPath, $newContent)) { - $this->info('βœ… Resource registered successfully in config/mcp-server.php'); + if (property_exists($this, 'output') && $this->output) { + $this->info('βœ… Resource registered successfully in config/mcp-server.php'); + } return true; } else { - $this->error('❌ Failed to update config file. Please manually register the resource.'); + if (property_exists($this, 'output') && $this->output) { + $this->error('❌ Failed to update config file. Please manually register the resource.'); + } return false; } diff --git a/src/Console/Commands/MakeMcpToolCommand.php b/src/Console/Commands/MakeMcpToolCommand.php index c868633..c96b194 100644 --- a/src/Console/Commands/MakeMcpToolCommand.php +++ b/src/Console/Commands/MakeMcpToolCommand.php @@ -71,30 +71,36 @@ public function handle() $this->info("βœ… Created: {$path}"); - $fullClassName = "\\App\\MCP\\Tools\\{$className}"; - - // Ask if they want to automatically register the tool (skip in programmatic mode) - if (! $this->option('programmatic')) { - if ($this->confirm('πŸ€– Would you like to automatically register this tool in config/mcp-server.php?', true)) { - $this->registerToolInConfig($fullClassName); - } else { - $this->info("β˜‘οΈ Don't forget to register your tool in config/mcp-server.php:"); - $this->comment(' // config/mcp-server.php'); - $this->comment(" 'tools' => ["); - $this->comment(' // other tools...'); - $this->comment(" {$fullClassName}::class,"); - $this->comment(' ],'); - } + // Build full class name with tag directory support + $tagDirectory = $this->dynamicParams['tagDirectory'] ?? ''; + $fullClassName = '\\App\\MCP\\Tools\\'; + if ($tagDirectory) { + $fullClassName .= "{$tagDirectory}\\"; + } + $fullClassName .= $className; + + // Ask if they want to automatically register the tool + if ($this->option('programmatic') || $this->option('no-interaction')) { + // In programmatic or no-interaction mode, always register automatically + $this->registerToolInConfig($fullClassName); + } elseif ($this->confirm('πŸ€– Would you like to automatically register this tool in config/mcp-server.php?', true)) { + $this->registerToolInConfig($fullClassName); + } else { + $this->info("β˜‘οΈ Don't forget to register your tool in config/mcp-server.php:"); + $this->comment(' // config/mcp-server.php'); + $this->comment(" 'tools' => ["); + $this->comment(' // other tools...'); + $this->comment(" {$fullClassName}::class,"); + $this->comment(' ],'); + } - // Display testing instructions + // Display testing instructions (skip in programmatic or no-interaction mode) + if (! $this->option('programmatic') && ! $this->option('no-interaction')) { $this->newLine(); $this->info('You can now test your tool with the following command:'); $this->comment(' php artisan mcp:test-tool '.$className); $this->info('Or view all available tools:'); $this->comment(' php artisan mcp:test-tool --list'); - } else { - // In programmatic mode, always register the tool - $this->registerToolInConfig($fullClassName); } return 0; @@ -143,6 +149,13 @@ protected function getClassName() */ protected function getPath(string $className) { + // Check if we have a tag directory from dynamic params + $tagDirectory = $this->dynamicParams['tagDirectory'] ?? ''; + + if ($tagDirectory) { + return app_path("MCP/Tools/{$tagDirectory}/{$className}.php"); + } + // Create the file in the app/MCP/Tools directory return app_path("MCP/Tools/{$className}.php"); } @@ -225,9 +238,16 @@ protected function buildDynamicClass(string $className): string // Build annotations $annotationsString = $this->arrayToPhpString($annotations, 2); + // Build namespace with tag directory support + $namespace = 'App\\MCP\\Tools'; + $tagDirectory = $params['tagDirectory'] ?? ''; + if ($tagDirectory) { + $namespace .= '\\'.$tagDirectory; + } + // Replace placeholders in stub $replacements = [ - '{{ namespace }}' => 'App\\MCP\\Tools', + '{{ namespace }}' => $namespace, '{{ className }}' => $className, '{{ toolName }}' => $toolName, '{{ description }}' => addslashes($description), @@ -290,9 +310,16 @@ protected function getStubPath() */ protected function replaceStubPlaceholders(string $stub, string $className, string $toolName) { + // Build namespace with tag directory support + $namespace = 'App\\MCP\\Tools'; + $tagDirectory = $this->dynamicParams['tagDirectory'] ?? ''; + if ($tagDirectory) { + $namespace .= '\\'.$tagDirectory; + } + return str_replace( ['{{ className }}', '{{ namespace }}', '{{ toolName }}'], - [$className, 'App\\MCP\\Tools', $toolName], + [$className, $namespace, $toolName], $stub ); } @@ -316,33 +343,49 @@ protected function registerToolInConfig(string $toolClassName): bool $content = file_get_contents($configPath); // Find the tools array in the config file - if (! preg_match('/[\'"]tools[\'"]\s*=>\s*\[(.*?)\s*\],/s', $content, $matches)) { + if (! preg_match('/[\'"]tools[\'"]\s*=>\s*\[(.*?)\],/s', $content, $matches)) { $this->error('❌ Could not locate tools array in config file.'); return false; } $toolsArrayContent = $matches[1]; - $fullEntry = "\n {$toolClassName}::class,"; + // Escape backslashes for the config file + $escapedClassName = str_replace('\\', '\\\\', $toolClassName); + $fullEntry = "\n {$escapedClassName}::class,"; - // Check if the tool is already registered - if (strpos($toolsArrayContent, $toolClassName) !== false) { + // Check if the tool is already registered (check both escaped and unescaped) + if (strpos($toolsArrayContent, $escapedClassName) !== false || strpos($toolsArrayContent, $toolClassName) !== false) { $this->info('βœ… Tool is already registered in config file.'); return true; } - // Add the new tool to the tools array - $newToolsArrayContent = $toolsArrayContent.$fullEntry; - $newContent = str_replace($toolsArrayContent, $newToolsArrayContent, $content); + // Handle empty array case + if (trim($toolsArrayContent) === '') { + // Empty array, add the entry directly + $newContent = str_replace( + "'tools' => []", + "'tools' => [{$fullEntry}\n ]", + $content + ); + } else { + // Add the new tool to the tools array + $newToolsArrayContent = $toolsArrayContent.$fullEntry; + $newContent = str_replace($toolsArrayContent, $newToolsArrayContent, $content); + } // Write the updated content back to the config file if (file_put_contents($configPath, $newContent)) { - $this->info('βœ… Tool registered successfully in config/mcp-server.php'); + if (property_exists($this, 'output') && $this->output) { + $this->info('βœ… Tool registered successfully in config/mcp-server.php'); + } return true; } else { - $this->error('❌ Failed to update config file. Please manually register the tool.'); + if (property_exists($this, 'output') && $this->output) { + $this->error('❌ Failed to update config file. Please manually register the tool.'); + } return false; } diff --git a/src/Console/Commands/MakeSwaggerMcpToolCommand.php b/src/Console/Commands/MakeSwaggerMcpToolCommand.php index bae150a..405ba44 100644 --- a/src/Console/Commands/MakeSwaggerMcpToolCommand.php +++ b/src/Console/Commands/MakeSwaggerMcpToolCommand.php @@ -4,6 +4,7 @@ use Illuminate\Console\Command; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Str; use OPGG\LaravelMcpServer\Services\SwaggerParser\SwaggerParser; use OPGG\LaravelMcpServer\Services\SwaggerParser\SwaggerToMcpConverter; @@ -15,8 +16,9 @@ class MakeSwaggerMcpToolCommand extends Command * @var string */ protected $signature = 'make:swagger-mcp-tool {source : Swagger/OpenAPI spec URL or file path} + {--force : Overwrite existing files} {--test-api : Test API endpoints before generating tools} - {--group-by=tag : Group endpoints by (tag|path|none)} + {--group-by= : Group endpoints by tag or path (tag|path|none)} {--prefix= : Prefix for generated tool class names}'; /** @@ -32,13 +34,16 @@ class MakeSwaggerMcpToolCommand extends Command protected array $authConfig = []; - protected array $selectedEndpoints = []; - /** * Selected endpoints with their generation type */ protected array $selectedEndpointsWithType = []; + /** + * Selected grouping method + */ + protected string $groupingMethod; + /** * Execute the console command. * @@ -86,6 +91,9 @@ protected function selectEndpointsWithTypes(): void $endpoints = $this->parser->getEndpoints(); if ($this->option('no-interaction')) { + // Set grouping method for non-interactive mode + $this->groupingMethod = $this->getGroupingOption(); + // In non-interactive mode, use smart defaults foreach ($endpoints as $endpoint) { if ($endpoint['deprecated']) { @@ -113,11 +121,11 @@ protected function selectEndpointsWithTypes(): void $this->comment('Tip: GET endpoints are typically Resources, while POST/PUT/DELETE are Tools'); $this->newLine(); - $groupBy = $this->option('group-by'); + $this->groupingMethod = $this->getGroupingOption(); - if ($groupBy === 'tag') { + if ($this->groupingMethod === 'tag') { $this->selectByTagWithTypes(); - } elseif ($groupBy === 'path') { + } elseif ($this->groupingMethod === 'path') { $this->selectByPathWithTypes(); } else { $this->selectIndividuallyWithTypes(); @@ -129,6 +137,285 @@ protected function selectEndpointsWithTypes(): void $this->info("Selected {$toolCount} tools and {$resourceCount} resources."); } + /** + * Get the grouping option - prompt user if not provided + */ + protected function getGroupingOption(): string + { + $groupBy = $this->option('group-by'); + + // If grouping option is provided, return it + if ($groupBy) { + return $groupBy; + } + + // If no interaction is disabled or option not provided, ask user interactively + if (! $this->option('no-interaction')) { + $this->newLine(); + $this->info('πŸ—‚οΈ Choose how to organize your generated tools and resources:'); + $this->newLine(); + + // Generate previews for each grouping option + $previews = $this->generateGroupingPreviews(); + + $choices = [ + 'tag' => 'Tag-based grouping (organize by OpenAPI tags)', + 'path' => 'Path-based grouping (organize by API path)', + 'none' => 'No grouping (everything in root folder)', + ]; + + // Display previews + foreach ($choices as $key => $description) { + $this->line("{$description}"); + if (! empty($previews[$key])) { + foreach ($previews[$key] as $line) { + $this->line($line); + } + } else { + $this->line(' No examples available'); + } + $this->newLine(); + } + + $choice = $this->choice( + 'Select grouping method', + array_values($choices), + 0 // Default to first option (tag-based) + ); + + // Map choice back to key + $groupBy = array_search($choice, $choices); + + $this->info("Selected: {$choice}"); + $this->newLine(); + } else { + // Default to 'tag' for non-interactive mode + $groupBy = 'tag'; + } + + return $groupBy; + } + + /** + * Generate grouping previews to show users examples of how endpoints will be organized + */ + protected function generateGroupingPreviews(): array + { + $previews = [ + 'tag' => [], + 'path' => [], + 'none' => [], + ]; + + // Check if parser and converter are initialized + if (! isset($this->parser) || ! isset($this->converter)) { + return $previews; + } + + $endpoints = $this->parser->getEndpoints(); + $totalEndpoints = count($endpoints); + + // Calculate statistics for each grouping method + $tagStats = []; + $pathStats = []; + $totalTools = 0; + $totalResources = 0; + $noneExamples = []; + + foreach ($endpoints as $endpoint) { + $isResource = $endpoint['method'] === 'GET'; + if ($isResource) { + $totalResources++; + } else { + $totalTools++; + } + + // Store example for no-grouping + if (count($noneExamples) < 3) { + $className = $this->converter->generateClassName($endpoint, ''); + $type = $isResource ? 'Resources' : 'Tools'; + $noneExamples[] = ['className' => $className, 'type' => $type, 'endpoint' => $endpoint]; + } + + // Tag-based statistics + if (! empty($endpoint['tags'])) { + foreach ($endpoint['tags'] as $tag) { + $directory = Str::studly(str_replace(['/', '.', '@', '-', '_'], ' ', $tag)); + if (! isset($tagStats[$directory])) { + $tagStats[$directory] = ['tools' => 0, 'resources' => 0, 'original' => $tag, 'examples' => []]; + } + if ($isResource) { + $tagStats[$directory]['resources']++; + } else { + $tagStats[$directory]['tools']++; + } + // Store up to 2 examples per tag + if (count($tagStats[$directory]['examples']) < 2) { + $className = $this->converter->generateClassName($endpoint, ''); + $tagStats[$directory]['examples'][] = [ + 'className' => $className, + 'method' => $endpoint['method'], + 'path' => $endpoint['path'], + ]; + } + } + } else { + if (! isset($tagStats['General'])) { + $tagStats['General'] = ['tools' => 0, 'resources' => 0, 'original' => 'General', 'examples' => []]; + } + if ($isResource) { + $tagStats['General']['resources']++; + } else { + $tagStats['General']['tools']++; + } + if (count($tagStats['General']['examples']) < 2) { + $className = $this->converter->generateClassName($endpoint, ''); + $tagStats['General']['examples'][] = [ + 'className' => $className, + 'method' => $endpoint['method'], + 'path' => $endpoint['path'], + ]; + } + } + + // Path-based statistics + $parts = explode('/', trim($endpoint['path'], '/')); + $firstSegment = ! empty($parts[0]) ? $parts[0] : 'Root'; + $directory = Str::studly($firstSegment); + if (! isset($pathStats[$directory])) { + $pathStats[$directory] = ['tools' => 0, 'resources' => 0, 'original' => $firstSegment, 'examples' => []]; + } + if ($isResource) { + $pathStats[$directory]['resources']++; + } else { + $pathStats[$directory]['tools']++; + } + // Store up to 2 examples per path group + if (count($pathStats[$directory]['examples']) < 2) { + $className = $this->converter->generateClassName($endpoint, ''); + $pathStats[$directory]['examples'][] = [ + 'className' => $className, + 'method' => $endpoint['method'], + 'path' => $endpoint['path'], + ]; + } + } + + // Format tag-based preview + $previews['tag'][] = "πŸ“Š Total: {$totalEndpoints} endpoints β†’ {$totalTools} tools + {$totalResources} resources"; + $previews['tag'][] = ''; + + $tagCount = 0; + foreach ($tagStats as $dir => $stats) { + if ($tagCount >= 5) { + $remaining = count($tagStats) - $tagCount; + $previews['tag'][] = " ... and {$remaining} more tag groups"; + break; + } + + $label = $stats['original'] !== $dir ? "{$stats['original']} β†’ {$dir}" : $dir; + + if ($stats['tools'] > 0 && $stats['resources'] > 0) { + $previews['tag'][] = " πŸ“ {$dir}/ ({$stats['tools']} tools, {$stats['resources']} resources)"; + } elseif ($stats['tools'] > 0) { + $previews['tag'][] = " πŸ“ Tools/{$dir}/ ({$stats['tools']} tools)"; + } else { + $previews['tag'][] = " πŸ“ Resources/{$dir}/ ({$stats['resources']} resources)"; + } + + // Add examples for this tag + foreach ($stats['examples'] as $idx => $example) { + $previews['tag'][] = " └─ {$example['className']}.php ({$example['method']} {$example['path']})"; + } + + // Show if there are more files in this group + $totalInGroup = $stats['tools'] + $stats['resources']; + if ($totalInGroup > count($stats['examples'])) { + $remaining = $totalInGroup - count($stats['examples']); + $previews['tag'][] = " └─ ... and {$remaining} more files"; + } + + $tagCount++; + } + + // Format path-based preview + $previews['path'][] = "πŸ“Š Total: {$totalEndpoints} endpoints β†’ {$totalTools} tools + {$totalResources} resources"; + $previews['path'][] = ''; + + $pathCount = 0; + foreach ($pathStats as $dir => $stats) { + if ($pathCount >= 5) { + $remaining = count($pathStats) - $pathCount; + $previews['path'][] = " ... and {$remaining} more path groups"; + break; + } + + $label = "/{$stats['original']}"; + + if ($stats['tools'] > 0 && $stats['resources'] > 0) { + $previews['path'][] = " πŸ“ {$dir}/ ({$stats['tools']} tools, {$stats['resources']} resources from {$label})"; + } elseif ($stats['tools'] > 0) { + $previews['path'][] = " πŸ“ Tools/{$dir}/ ({$stats['tools']} tools from {$label})"; + } else { + $previews['path'][] = " πŸ“ Resources/{$dir}/ ({$stats['resources']} resources from {$label})"; + } + + // Add examples for this path group + foreach ($stats['examples'] as $idx => $example) { + $previews['path'][] = " └─ {$example['className']}.php ({$example['method']} {$example['path']})"; + } + + // Show if there are more files in this group + $totalInGroup = $stats['tools'] + $stats['resources']; + if ($totalInGroup > count($stats['examples'])) { + $remaining = $totalInGroup - count($stats['examples']); + $previews['path'][] = " └─ ... and {$remaining} more files"; + } + + $pathCount++; + } + + // Format no-grouping preview + $previews['none'][] = "πŸ“Š Total: {$totalEndpoints} endpoints β†’ {$totalTools} tools + {$totalResources} resources"; + $previews['none'][] = ''; + + if ($totalTools > 0) { + $previews['none'][] = " πŸ“ Tools/ ({$totalTools} files directly in root)"; + + // Add tool examples + $toolExampleCount = 0; + foreach ($noneExamples as $example) { + if ($example['type'] === 'Tools' && $toolExampleCount < 2) { + $previews['none'][] = " └─ {$example['className']}.php ({$example['endpoint']['method']} {$example['endpoint']['path']})"; + $toolExampleCount++; + } + } + if ($totalTools > $toolExampleCount) { + $remaining = $totalTools - $toolExampleCount; + $previews['none'][] = " └─ ... and {$remaining} more files"; + } + } + + if ($totalResources > 0) { + $previews['none'][] = " πŸ“ Resources/ ({$totalResources} files directly in root)"; + + // Add resource examples + $resourceExampleCount = 0; + foreach ($noneExamples as $example) { + if ($example['type'] === 'Resources' && $resourceExampleCount < 2) { + $previews['none'][] = " └─ {$example['className']}.php ({$example['endpoint']['method']} {$example['endpoint']['path']})"; + $resourceExampleCount++; + } + } + if ($totalResources > $resourceExampleCount) { + $remaining = $totalResources - $resourceExampleCount; + $previews['none'][] = " └─ ... and {$remaining} more files"; + } + } + + return $previews; + } + /** * Load and validate the Swagger/OpenAPI spec */ @@ -242,7 +529,6 @@ protected function selectIndividuallyWithTypes(): void if ($this->confirm("Include: {$label}?", ! $endpoint['deprecated'])) { // Ask for type - $defaultType = $endpoint['method'] === 'GET' ? 'Resource' : 'Tool'; $typeChoice = $this->choice( 'Generate as', ['Tool (for actions)', 'Resource (for read-only data)', 'Skip'], @@ -290,9 +576,6 @@ protected function selectByTagWithTypes(): void continue; } - // Smart default based on method - $defaultType = $endpoint['method'] === 'GET' ? 'resource' : 'tool'; - $endpointLabel = "{$endpoint['method']} {$endpoint['path']}"; if ($endpoint['summary']) { $endpointLabel .= " - {$endpoint['summary']}"; @@ -380,124 +663,6 @@ protected function selectByPathWithTypes(): void } } - /** - * Select endpoints to generate tools for (OLD - kept for compatibility) - */ - protected function selectEndpoints(): void - { - $endpoints = $this->parser->getEndpoints(); - - if ($this->option('no-interaction')) { - // In non-interactive mode, select appropriate endpoints based on type - if ($this->generateType === 'resource') { - // For resources, only select GET endpoints - $this->selectedEndpoints = array_filter($endpoints, fn ($e) => ! $e['deprecated'] && $e['method'] === 'GET'); - $this->info('Selected '.count($this->selectedEndpoints).' GET endpoints for resources (excluding deprecated).'); - } else { - // For tools, select all non-deprecated endpoints - $this->selectedEndpoints = array_filter($endpoints, fn ($e) => ! $e['deprecated']); - $this->info('Selected '.count($this->selectedEndpoints).' endpoints (excluding deprecated).'); - } - - return; - } - - $componentType = $this->generateType === 'resource' ? 'resources' : 'tools'; - $this->info("πŸ“‹ Select endpoints to generate {$componentType} for:"); - - if ($this->generateType === 'resource') { - $this->comment('Note: Only GET endpoints can be converted to resources'); - } - - $groupBy = $this->option('group-by'); - - if ($groupBy === 'tag') { - $this->selectByTag(); - } elseif ($groupBy === 'path') { - $this->selectByPath(); - } else { - $this->selectIndividually(); - } - - $this->info('Selected '.count($this->selectedEndpoints).' endpoints.'); - } - - /** - * Select endpoints by tag - */ - protected function selectByTag(): void - { - $byTag = $this->parser->getEndpointsByTag(); - - foreach ($byTag as $tag => $endpoints) { - $count = count($endpoints); - $deprecated = count(array_filter($endpoints, fn ($e) => $e['deprecated'])); - - $label = "{$tag} ({$count} endpoints"; - if ($deprecated > 0) { - $label .= ", {$deprecated} deprecated"; - } - $label .= ')'; - - if ($this->confirm("Include tag: {$label}?", true)) { - foreach ($endpoints as $endpoint) { - if (! $endpoint['deprecated'] || $this->confirm("Include deprecated: {$endpoint['method']} {$endpoint['path']}?", false)) { - $this->selectedEndpoints[] = $endpoint; - } - } - } - } - } - - /** - * Select endpoints by path prefix - */ - protected function selectByPath(): void - { - $byPath = []; - - foreach ($this->parser->getEndpoints() as $endpoint) { - $parts = explode('/', trim($endpoint['path'], '/')); - $prefix = ! empty($parts[0]) ? $parts[0] : 'root'; - if (! isset($byPath[$prefix])) { - $byPath[$prefix] = []; - } - $byPath[$prefix][] = $endpoint; - } - - foreach ($byPath as $prefix => $endpoints) { - $count = count($endpoints); - - if ($this->confirm("Include path prefix '/{$prefix}' ({$count} endpoints)?", true)) { - foreach ($endpoints as $endpoint) { - if (! $endpoint['deprecated'] || $this->confirm("Include deprecated: {$endpoint['method']} {$endpoint['path']}?", false)) { - $this->selectedEndpoints[] = $endpoint; - } - } - } - } - } - - /** - * Select endpoints individually - */ - protected function selectIndividually(): void - { - foreach ($this->parser->getEndpoints() as $endpoint) { - $label = "{$endpoint['method']} {$endpoint['path']}"; - if ($endpoint['summary']) { - $label .= " - {$endpoint['summary']}"; - } - if ($endpoint['deprecated']) { - $label .= ' [DEPRECATED]'; - } - - if ($this->confirm("Include: {$label}?", ! $endpoint['deprecated'])) { - $this->selectedEndpoints[] = $endpoint; - } - } - } - /** * Configure authentication */ @@ -572,7 +737,10 @@ protected function generateComponents(): void if ($type === 'tool') { // Generate tool $className = $this->converter->generateClassName($endpoint, $prefix); - $path = app_path("MCP/Tools/{$className}.php"); + + // Create directory structure based on grouping strategy + $directory = $this->createDirectory($endpoint); + $path = $directory ? app_path("MCP/Tools/{$directory}/{$className}.php") : app_path("MCP/Tools/{$className}.php"); if (file_exists($path)) { $this->warn("Skipping {$className} - already exists"); @@ -585,6 +753,9 @@ protected function generateComponents(): void // Get tool parameters $toolParams = $this->converter->convertEndpointToTool($endpoint, $className); + // Add tag directory to tool params for namespace handling + $toolParams['tagDirectory'] = $directory; + // Create the tool using MakeMcpToolCommand $makeTool = new MakeMcpToolCommand(app('files')); $makeTool->setLaravel($this->getLaravel()); @@ -608,7 +779,10 @@ protected function generateComponents(): void } else { // Generate resource $className = $this->converter->generateResourceClassName($endpoint, $prefix); - $path = app_path("MCP/Resources/{$className}.php"); + + // Create directory structure based on grouping strategy + $directory = $this->createDirectory($endpoint); + $path = $directory ? app_path("MCP/Resources/{$directory}/{$className}.php") : app_path("MCP/Resources/{$className}.php"); if (file_exists($path)) { $this->warn("Skipping {$className} - already exists"); @@ -621,6 +795,9 @@ protected function generateComponents(): void // Get resource parameters $resourceParams = $this->converter->convertEndpointToResource($endpoint, $className); + // Add tag directory to resource params for namespace handling + $resourceParams['tagDirectory'] = $directory; + // Create the resource using MakeMcpResourceCommand $makeResource = new MakeMcpResourceCommand(app('files')); $makeResource->setLaravel($this->getLaravel()); @@ -689,4 +866,60 @@ protected function generateComponents(): void $this->line("{$stepNumber}. Update authentication configuration if needed"); } } + + /** + * Create a directory name based on grouping strategy + */ + protected function createDirectory(array $endpoint): string + { + switch ($this->groupingMethod) { + case 'tag': + return $this->createTagDirectory($endpoint); + case 'path': + return $this->createPathDirectory($endpoint); + default: + return ''; // No subdirectory for 'none' grouping + } + } + + /** + * Create a tag-based directory name from endpoint tags + */ + protected function createTagDirectory(array $endpoint): string + { + // Get the first tag, or use a default + $tags = $endpoint['tags'] ?? []; + if (empty($tags)) { + return 'General'; + } + + $tag = $tags[0]; // Use the first tag + + // Check if tag is empty or whitespace only + if (trim($tag) === '') { + return 'General'; + } + + // Remove special characters and convert to StudlyCase + // Replace special characters with spaces first + $tag = str_replace(['/', '.', '@', '-', '_'], ' ', $tag); + + // Convert to StudlyCase for directory naming + return Str::studly($tag); + } + + /** + * Create a path-based directory name from endpoint path + */ + protected function createPathDirectory(array $endpoint): string + { + $path = $endpoint['path'] ?? ''; + $parts = explode('/', trim($path, '/')); + + // Use the first path segment, or 'Root' if no segments + $firstSegment = ! empty($parts[0]) ? $parts[0] : 'Root'; + + // Convert to StudlyCase for directory naming + return Str::studly($firstSegment); + } } diff --git a/src/Console/Commands/MigrateToolsCommand.php b/src/Console/Commands/MigrateToolsCommand.php index fad9d81..7fb9a5b 100644 --- a/src/Console/Commands/MigrateToolsCommand.php +++ b/src/Console/Commands/MigrateToolsCommand.php @@ -65,19 +65,23 @@ public function handle() $potentialCandidates++; // Ask about backup creation only once - if ($createBackups === null && ! $this->option('no-backup')) { - $createBackups = $this->confirm( - 'Do you want to create backup files before migration? (Recommended)', - true // Default to yes - ); - - if ($createBackups) { - $this->info('Backup files will be created with .backup extension.'); + if ($createBackups === null) { + if ($this->option('no-backup')) { + $createBackups = false; + } elseif ($this->option('no-interaction')) { + $createBackups = true; // Default to yes in no-interaction mode } else { - $this->warn('No backup files will be created. Migration will modify files directly.'); + $createBackups = $this->confirm( + 'Do you want to create backup files before migration? (Recommended)', + true // Default to yes + ); + + if ($createBackups) { + $this->info('Backup files will be created with .backup extension.'); + } else { + $this->warn('No backup files will be created. Migration will modify files directly.'); + } } - } elseif ($this->option('no-backup')) { - $createBackups = false; } $backupFilePath = $filePath.'.backup'; diff --git a/tests/Console/Commands/MakeMcpResourceCommandTest.php b/tests/Console/Commands/MakeMcpResourceCommandTest.php index 768e7e4..5a40135 100644 --- a/tests/Console/Commands/MakeMcpResourceCommandTest.php +++ b/tests/Console/Commands/MakeMcpResourceCommandTest.php @@ -2,16 +2,109 @@ use Illuminate\Support\Facades\File; +beforeEach(function () { + // Create a minimal config file for testing + $configDir = config_path(); + if (! File::isDirectory($configDir)) { + File::makeDirectory($configDir, 0755, true); + } + + $configContent = " [],\n 'resources' => [],\n];"; + File::put(config_path('mcp-server.php'), $configContent); +}); + afterEach(function () { File::deleteDirectory(app_path('MCP/Resources')); + if (File::exists(config_path('mcp-server.php'))) { + File::delete(config_path('mcp-server.php')); + } }); test('make:mcp-resource generates a resource class', function () { $path = app_path('MCP/Resources/TestResource.php'); - $this->artisan('make:mcp-resource', ['name' => 'Test']) + $this->artisan('make:mcp-resource', ['name' => 'Test', '--no-interaction' => true]) ->expectsOutputToContain('Created') ->assertExitCode(0); expect(File::exists($path))->toBeTrue(); }); + +test('getPath returns correct path without tag directory', function () { + $filesystem = new \Illuminate\Filesystem\Filesystem; + $command = new \OPGG\LaravelMcpServer\Console\Commands\MakeMcpResourceCommand($filesystem); + + $method = new ReflectionMethod($command, 'getPath'); + $method->setAccessible(true); + + $result = $method->invoke($command, 'TestResource'); + $expected = app_path('MCP/Resources/TestResource.php'); + + expect($result)->toBe($expected); +}); + +test('getPath returns correct path with tag directory', function () { + $filesystem = new \Illuminate\Filesystem\Filesystem; + $command = new \OPGG\LaravelMcpServer\Console\Commands\MakeMcpResourceCommand($filesystem); + + // Set dynamicParams using reflection + $property = new ReflectionProperty($command, 'dynamicParams'); + $property->setAccessible(true); + $property->setValue($command, ['tagDirectory' => 'Pet']); + + $method = new ReflectionMethod($command, 'getPath'); + $method->setAccessible(true); + + $result = $method->invoke($command, 'PetResource'); + $expected = app_path('MCP/Resources/Pet/PetResource.php'); + + expect($result)->toBe($expected); +}); + +test('replaceStubPlaceholders generates correct namespace without tag directory', function () { + $filesystem = new \Illuminate\Filesystem\Filesystem; + $command = new \OPGG\LaravelMcpServer\Console\Commands\MakeMcpResourceCommand($filesystem); + + $method = new ReflectionMethod($command, 'replaceStubPlaceholders'); + $method->setAccessible(true); + + $stub = 'namespace {{ namespace }}; class {{ className }} { }'; + $result = $method->invoke($command, $stub, 'TestResource'); + + expect($result)->toContain('namespace App\\MCP\\Resources;'); + expect($result)->toContain('class TestResource'); +}); + +test('replaceStubPlaceholders generates correct namespace with tag directory', function () { + $filesystem = new \Illuminate\Filesystem\Filesystem; + $command = new \OPGG\LaravelMcpServer\Console\Commands\MakeMcpResourceCommand($filesystem); + + // Set dynamicParams using reflection + $property = new ReflectionProperty($command, 'dynamicParams'); + $property->setAccessible(true); + $property->setValue($command, ['tagDirectory' => 'Pet']); + + $method = new ReflectionMethod($command, 'replaceStubPlaceholders'); + $method->setAccessible(true); + + $stub = 'namespace {{ namespace }}; class {{ className }} { }'; + $result = $method->invoke($command, $stub, 'PetResource'); + + expect($result)->toContain('namespace App\\MCP\\Resources\\Pet;'); + expect($result)->toContain('class PetResource'); +}); + +test('makeDirectory creates nested directories for resources', function () { + $filesystem = new \Illuminate\Filesystem\Filesystem; + $command = new \OPGG\LaravelMcpServer\Console\Commands\MakeMcpResourceCommand($filesystem); + + $method = new ReflectionMethod($command, 'makeDirectory'); + $method->setAccessible(true); + + $testPath = app_path('MCP/Resources/Pet/Store/TestResource.php'); + $result = $method->invoke($command, $testPath); + + $expectedDirectory = dirname($testPath); + expect($result)->toBe($expectedDirectory); + expect(File::isDirectory($expectedDirectory))->toBeTrue(); +}); diff --git a/tests/Console/Commands/MakeMcpToolCommandTest.php b/tests/Console/Commands/MakeMcpToolCommandTest.php new file mode 100644 index 0000000..41f09f6 --- /dev/null +++ b/tests/Console/Commands/MakeMcpToolCommandTest.php @@ -0,0 +1,206 @@ + [],\n 'resources' => [],\n];"; + File::put(config_path('mcp-server.php'), $configContent); +}); + +afterEach(function () { + // Clean up after each test + File::deleteDirectory(app_path('MCP/Tools')); + if (File::exists(config_path('mcp-server.php'))) { + File::delete(config_path('mcp-server.php')); + } +}); + +test('make:mcp-tool generates tool in root directory by default', function () { + $this->artisan('make:mcp-tool', ['name' => 'TestTool', '--no-interaction' => true]) + ->expectsOutputToContain('Created') + ->assertExitCode(0); + + $path = app_path('MCP/Tools/TestTool.php'); + expect(File::exists($path))->toBeTrue(); + + // Verify namespace is correct + $content = File::get($path); + expect($content)->toContain('namespace App\\MCP\\Tools;'); +}); + +test('getPath returns correct path without tag directory', function () { + $filesystem = new \Illuminate\Filesystem\Filesystem; + $command = new \OPGG\LaravelMcpServer\Console\Commands\MakeMcpToolCommand($filesystem); + + $method = new ReflectionMethod($command, 'getPath'); + $method->setAccessible(true); + + $result = $method->invoke($command, 'TestTool'); + $expected = app_path('MCP/Tools/TestTool.php'); + + expect($result)->toBe($expected); +}); + +test('getPath returns correct path with tag directory', function () { + $filesystem = new \Illuminate\Filesystem\Filesystem; + $command = new \OPGG\LaravelMcpServer\Console\Commands\MakeMcpToolCommand($filesystem); + + // Set dynamicParams using reflection + $property = new ReflectionProperty($command, 'dynamicParams'); + $property->setAccessible(true); + $property->setValue($command, ['tagDirectory' => 'Pet']); + + $method = new ReflectionMethod($command, 'getPath'); + $method->setAccessible(true); + + $result = $method->invoke($command, 'TestTool'); + $expected = app_path('MCP/Tools/Pet/TestTool.php'); + + expect($result)->toBe($expected); +}); + +test('replaceStubPlaceholders generates correct namespace without tag directory', function () { + $filesystem = new \Illuminate\Filesystem\Filesystem; + $command = new \OPGG\LaravelMcpServer\Console\Commands\MakeMcpToolCommand($filesystem); + + $method = new ReflectionMethod($command, 'replaceStubPlaceholders'); + $method->setAccessible(true); + + $stub = 'namespace {{ namespace }}; class {{ className }} { public function name() { return "{{ toolName }}"; } }'; + $result = $method->invoke($command, $stub, 'TestTool', 'test-tool'); + + expect($result)->toContain('namespace App\\MCP\\Tools;'); + expect($result)->toContain('class TestTool'); + expect($result)->toContain('return "test-tool";'); +}); + +test('replaceStubPlaceholders generates correct namespace with tag directory', function () { + $filesystem = new \Illuminate\Filesystem\Filesystem; + $command = new \OPGG\LaravelMcpServer\Console\Commands\MakeMcpToolCommand($filesystem); + + // Set dynamicParams using reflection + $property = new ReflectionProperty($command, 'dynamicParams'); + $property->setAccessible(true); + $property->setValue($command, ['tagDirectory' => 'Pet']); + + $method = new ReflectionMethod($command, 'replaceStubPlaceholders'); + $method->setAccessible(true); + + $stub = 'namespace {{ namespace }}; class {{ className }} { public function name() { return "{{ toolName }}"; } }'; + $result = $method->invoke($command, $stub, 'AddPetTool', 'add-pet'); + + expect($result)->toContain('namespace App\\MCP\\Tools\\Pet;'); + expect($result)->toContain('class AddPetTool'); + expect($result)->toContain('return "add-pet";'); +}); + +test('makeDirectory creates nested directories', function () { + $filesystem = new \Illuminate\Filesystem\Filesystem; + $command = new \OPGG\LaravelMcpServer\Console\Commands\MakeMcpToolCommand($filesystem); + + $method = new ReflectionMethod($command, 'makeDirectory'); + $method->setAccessible(true); + + $testPath = app_path('MCP/Tools/Pet/Store/TestTool.php'); + $result = $method->invoke($command, $testPath); + + $expectedDirectory = dirname($testPath); + expect($result)->toBe($expectedDirectory); + expect(File::isDirectory($expectedDirectory))->toBeTrue(); +}); + +test('tool with tag directory is properly registered in config', function () { + // Create a tool using dynamicParams (simulating swagger generation) + $filesystem = new \Illuminate\Filesystem\Filesystem; + $command = new \OPGG\LaravelMcpServer\Console\Commands\MakeMcpToolCommand($filesystem); + + // Set up the command with tag directory + $property = new ReflectionProperty($command, 'dynamicParams'); + $property->setAccessible(true); + $property->setValue($command, ['tagDirectory' => 'Pet']); + + // Generate the tool manually to test registration + $className = 'AddPetTool'; + $toolName = 'add-pet'; + + $path = app_path('MCP/Tools/Pet/AddPetTool.php'); + $directory = dirname($path); + if (! File::isDirectory($directory)) { + File::makeDirectory($directory, 0755, true); + } + + // Create a mock tool file + $toolContent = ' "success"]; + } + + public function messageType(): string + { + return "text"; + } +}'; + + File::put($path, $toolContent); + + // Test that the tool can be registered with the correct fully qualified class name + $fullyQualifiedClassName = 'App\\MCP\\Tools\\Pet\\AddPetTool'; + + $method = new ReflectionMethod($command, 'registerToolInConfig'); + $method->setAccessible(true); + + $result = $method->invoke($command, $fullyQualifiedClassName); + expect($result)->toBeTrue(); + + // Verify the tool was added to config (with escaped backslashes) + $configContent = File::get(config_path('mcp-server.php')); + $escapedClassName = str_replace('\\', '\\\\', $fullyQualifiedClassName); + expect($configContent)->toContain($escapedClassName); +}); + +test('handles directory creation permissions gracefully', function () { + $filesystem = new \Illuminate\Filesystem\Filesystem; + $command = new \OPGG\LaravelMcpServer\Console\Commands\MakeMcpToolCommand($filesystem); + + $method = new ReflectionMethod($command, 'makeDirectory'); + $method->setAccessible(true); + + // Test with a valid path - should not throw exception + $validPath = app_path('MCP/Tools/TestDir/TestTool.php'); + $result = $method->invoke($command, $validPath); + + expect($result)->toBe(dirname($validPath)); + expect(File::isDirectory(dirname($validPath)))->toBeTrue(); +}); diff --git a/tests/Console/Commands/MakeSwaggerMcpToolCommandTest.php b/tests/Console/Commands/MakeSwaggerMcpToolCommandTest.php new file mode 100644 index 0000000..58f9593 --- /dev/null +++ b/tests/Console/Commands/MakeSwaggerMcpToolCommandTest.php @@ -0,0 +1,581 @@ + [],\n 'resources' => [],\n];"; + File::put(config_path('mcp-server.php'), $configContent); +}); + +afterEach(function () { + // Clean up after each test + File::deleteDirectory(app_path('MCP/Tools')); + File::deleteDirectory(app_path('MCP/Resources')); + if (File::exists(config_path('mcp-server.php'))) { + File::delete(config_path('mcp-server.php')); + } +}); + +// Test tag-based directory creation +test('createDirectory returns tag-based directory by default', function () { + $command = new \OPGG\LaravelMcpServer\Console\Commands\MakeSwaggerMcpToolCommand; + + // Mock the command and set the groupingMethod property + $command = \Mockery::mock($command)->makePartial(); + + // Use reflection to set the groupingMethod property + $property = new ReflectionProperty($command, 'groupingMethod'); + $property->setAccessible(true); + $property->setValue($command, 'tag'); + + $method = new ReflectionMethod($command, 'createDirectory'); + $method->setAccessible(true); + + $endpoint = ['tags' => ['pet']]; + $result = $method->invoke($command, $endpoint); + + expect($result)->toBe('Pet'); +}); + +test('createDirectory returns path-based directory', function () { + $command = new \OPGG\LaravelMcpServer\Console\Commands\MakeSwaggerMcpToolCommand; + + // Mock the command and set the groupingMethod property + $command = \Mockery::mock($command)->makePartial(); + + // Use reflection to set the groupingMethod property + $property = new ReflectionProperty($command, 'groupingMethod'); + $property->setAccessible(true); + $property->setValue($command, 'path'); + + $method = new ReflectionMethod($command, 'createDirectory'); + $method->setAccessible(true); + + $endpoint = ['path' => '/users/profile']; + $result = $method->invoke($command, $endpoint); + + expect($result)->toBe('Users'); +}); + +test('createDirectory returns General for none grouping', function () { + $command = new \OPGG\LaravelMcpServer\Console\Commands\MakeSwaggerMcpToolCommand; + + // Mock the command and set the groupingMethod property + $command = \Mockery::mock($command)->makePartial(); + + // Use reflection to set the groupingMethod property + $property = new ReflectionProperty($command, 'groupingMethod'); + $property->setAccessible(true); + $property->setValue($command, 'none'); + + $method = new ReflectionMethod($command, 'createDirectory'); + $method->setAccessible(true); + + $endpoint = ['tags' => ['pet']]; + $result = $method->invoke($command, $endpoint); + + expect($result)->toBe('General'); +}); + +test('createTagDirectory returns StudlyCase for single tag', function () { + $filesystem = new \Illuminate\Filesystem\Filesystem; + $command = new \OPGG\LaravelMcpServer\Console\Commands\MakeSwaggerMcpToolCommand($filesystem); + + // Use reflection to access protected method + $method = new ReflectionMethod($command, 'createTagDirectory'); + $method->setAccessible(true); + + $endpoint = ['tags' => ['pet']]; + $result = $method->invoke($command, $endpoint); + + expect($result)->toBe('Pet'); +}); + +test('createTagDirectory returns General for empty tags', function () { + $filesystem = new \Illuminate\Filesystem\Filesystem; + $command = new \OPGG\LaravelMcpServer\Console\Commands\MakeSwaggerMcpToolCommand($filesystem); + + $method = new ReflectionMethod($command, 'createTagDirectory'); + $method->setAccessible(true); + + $endpoint = ['tags' => []]; + $result = $method->invoke($command, $endpoint); + + expect($result)->toBe('General'); +}); + +test('createTagDirectory returns General for missing tags key', function () { + $filesystem = new \Illuminate\Filesystem\Filesystem; + $command = new \OPGG\LaravelMcpServer\Console\Commands\MakeSwaggerMcpToolCommand($filesystem); + + $method = new ReflectionMethod($command, 'createTagDirectory'); + $method->setAccessible(true); + + $endpoint = []; + $result = $method->invoke($command, $endpoint); + + expect($result)->toBe('General'); +}); + +test('createTagDirectory uses first tag when multiple tags exist', function () { + $filesystem = new \Illuminate\Filesystem\Filesystem; + $command = new \OPGG\LaravelMcpServer\Console\Commands\MakeSwaggerMcpToolCommand($filesystem); + + $method = new ReflectionMethod($command, 'createTagDirectory'); + $method->setAccessible(true); + + $endpoint = ['tags' => ['store', 'inventory', 'user']]; + $result = $method->invoke($command, $endpoint); + + expect($result)->toBe('Store'); +}); + +test('createTagDirectory handles special characters in tags', function () { + $filesystem = new \Illuminate\Filesystem\Filesystem; + $command = new \OPGG\LaravelMcpServer\Console\Commands\MakeSwaggerMcpToolCommand($filesystem); + + $method = new ReflectionMethod($command, 'createTagDirectory'); + $method->setAccessible(true); + + $endpoint = ['tags' => ['user-management']]; + $result = $method->invoke($command, $endpoint); + + expect($result)->toBe('UserManagement'); +}); + +test('createTagDirectory handles snake_case tags', function () { + $filesystem = new \Illuminate\Filesystem\Filesystem; + $command = new \OPGG\LaravelMcpServer\Console\Commands\MakeSwaggerMcpToolCommand($filesystem); + + $method = new ReflectionMethod($command, 'createTagDirectory'); + $method->setAccessible(true); + + $endpoint = ['tags' => ['user_profile']]; + $result = $method->invoke($command, $endpoint); + + expect($result)->toBe('UserProfile'); +}); + +test('createTagDirectory handles numbers in tags', function () { + $filesystem = new \Illuminate\Filesystem\Filesystem; + $command = new \OPGG\LaravelMcpServer\Console\Commands\MakeSwaggerMcpToolCommand($filesystem); + + $method = new ReflectionMethod($command, 'createTagDirectory'); + $method->setAccessible(true); + + $endpoint = ['tags' => ['api-v2']]; + $result = $method->invoke($command, $endpoint); + + expect($result)->toBe('ApiV2'); +}); + +// Test path-based directory creation +test('createPathDirectory returns StudlyCase for path segments', function () { + $command = new \OPGG\LaravelMcpServer\Console\Commands\MakeSwaggerMcpToolCommand; + + $method = new ReflectionMethod($command, 'createPathDirectory'); + $method->setAccessible(true); + + $endpoint = ['path' => '/users/profile']; + $result = $method->invoke($command, $endpoint); + + expect($result)->toBe('Users'); +}); + +test('createPathDirectory returns Root for empty path', function () { + $command = new \OPGG\LaravelMcpServer\Console\Commands\MakeSwaggerMcpToolCommand; + + $method = new ReflectionMethod($command, 'createPathDirectory'); + $method->setAccessible(true); + + $endpoint = ['path' => '/']; + $result = $method->invoke($command, $endpoint); + + expect($result)->toBe('Root'); +}); + +test('createPathDirectory handles snake_case path segments', function () { + $command = new \OPGG\LaravelMcpServer\Console\Commands\MakeSwaggerMcpToolCommand; + + $method = new ReflectionMethod($command, 'createPathDirectory'); + $method->setAccessible(true); + + $endpoint = ['path' => '/user_profiles/details']; + $result = $method->invoke($command, $endpoint); + + expect($result)->toBe('UserProfiles'); +}); + +test('createPathDirectory handles kebab-case path segments', function () { + $command = new \OPGG\LaravelMcpServer\Console\Commands\MakeSwaggerMcpToolCommand; + + $method = new ReflectionMethod($command, 'createPathDirectory'); + $method->setAccessible(true); + + $endpoint = ['path' => '/api-v1/users']; + $result = $method->invoke($command, $endpoint); + + expect($result)->toBe('ApiV1'); +}); + +test('createPathDirectory handles missing path key', function () { + $command = new \OPGG\LaravelMcpServer\Console\Commands\MakeSwaggerMcpToolCommand; + + $method = new ReflectionMethod($command, 'createPathDirectory'); + $method->setAccessible(true); + + $endpoint = []; + $result = $method->invoke($command, $endpoint); + + expect($result)->toBe('Root'); +}); + +test('swagger tool generation creates tag-based directories', function () { + // Create a minimal swagger.json file + $swaggerData = [ + 'openapi' => '3.0.0', + 'info' => [ + 'title' => 'Test API', + 'version' => '1.0.0', + ], + 'paths' => [ + '/pet' => [ + 'post' => [ + 'tags' => ['pet'], + 'operationId' => 'addPet', + 'summary' => 'Add a new pet', + 'responses' => [ + '200' => ['description' => 'Success'], + ], + ], + ], + '/store/order' => [ + 'post' => [ + 'tags' => ['store'], + 'operationId' => 'placeOrder', + 'summary' => 'Place an order', + 'responses' => [ + '200' => ['description' => 'Success'], + ], + ], + ], + ], + ]; + + $swaggerPath = storage_path('swagger-test.json'); + File::put($swaggerPath, json_encode($swaggerData)); + + try { + $this->artisan('make:swagger-mcp-tool', [ + 'source' => $swaggerPath, + '--no-interaction' => true, + ]) + ->expectsOutputToContain('MCP components generated successfully!') + ->assertExitCode(0); + + // Check that tools were created in tag-based directories + $petToolPath = app_path('MCP/Tools/Pet/AddPetTool.php'); + $storeToolPath = app_path('MCP/Tools/Store/PlaceOrderTool.php'); + + expect(File::exists($petToolPath))->toBeTrue(); + expect(File::exists($storeToolPath))->toBeTrue(); + + // Verify namespace in generated files + $petToolContent = File::get($petToolPath); + expect($petToolContent)->toContain('namespace App\\MCP\\Tools\\Pet;'); + + $storeToolContent = File::get($storeToolPath); + expect($storeToolContent)->toContain('namespace App\\MCP\\Tools\\Store;'); + + } finally { + // Clean up + if (File::exists($swaggerPath)) { + File::delete($swaggerPath); + } + } +}); + +test('swagger tool generation handles untagged endpoints', function () { + // Create swagger with untagged endpoint - use POST to force tool generation + $swaggerData = [ + 'openapi' => '3.0.0', + 'info' => [ + 'title' => 'Test API', + 'version' => '1.0.0', + ], + 'paths' => [ + '/health' => [ + 'post' => [ + 'operationId' => 'healthCheck', + 'summary' => 'Health check', + 'responses' => [ + '200' => ['description' => 'Success'], + ], + ], + ], + ], + ]; + + $swaggerPath = storage_path('swagger-untagged-test.json'); + File::put($swaggerPath, json_encode($swaggerData)); + + try { + $this->artisan('make:swagger-mcp-tool', [ + 'source' => $swaggerPath, + '--no-interaction' => true, + ]) + ->expectsOutputToContain('MCP components generated successfully!') + ->assertExitCode(0); + + // Check that tool was created in General directory + $healthToolPath = app_path('MCP/Tools/General/HealthCheckTool.php'); + expect(File::exists($healthToolPath))->toBeTrue(); + + // Verify namespace + $healthToolContent = File::get($healthToolPath); + expect($healthToolContent)->toContain('namespace App\\MCP\\Tools\\General;'); + + } finally { + if (File::exists($swaggerPath)) { + File::delete($swaggerPath); + } + } +}); + +test('swagger tool generation creates path-based directories', function () { + // Create swagger with various path structures + $swaggerData = [ + 'openapi' => '3.0.0', + 'info' => [ + 'title' => 'Test API', + 'version' => '1.0.0', + ], + 'paths' => [ + '/users/profile' => [ + 'get' => [ + 'operationId' => 'getUserProfile', + 'summary' => 'Get user profile', + 'responses' => [ + '200' => ['description' => 'Success'], + ], + ], + ], + '/api/v1/orders' => [ + 'post' => [ + 'operationId' => 'createOrder', + 'summary' => 'Create an order', + 'responses' => [ + '201' => ['description' => 'Created'], + ], + ], + ], + ], + ]; + + $swaggerPath = storage_path('swagger-path-test.json'); + File::put($swaggerPath, json_encode($swaggerData)); + + try { + $this->artisan('make:swagger-mcp-tool', [ + 'source' => $swaggerPath, + '--group-by' => 'path', + '--no-interaction' => true, + ]) + ->expectsOutputToContain('MCP components generated successfully!') + ->assertExitCode(0); + + // Check that tools were created in path-based directories + $userResourcePath = app_path('MCP/Resources/Users/GetUserProfileResource.php'); + $apiToolPath = app_path('MCP/Tools/Api/CreateOrderTool.php'); + + expect(File::exists($userResourcePath))->toBeTrue(); + expect(File::exists($apiToolPath))->toBeTrue(); + + // Verify namespace in generated files + $userResourceContent = File::get($userResourcePath); + expect($userResourceContent)->toContain('namespace App\\MCP\\Resources\\Users;'); + + $apiToolContent = File::get($apiToolPath); + expect($apiToolContent)->toContain('namespace App\\MCP\\Tools\\Api;'); + + } finally { + // Clean up + if (File::exists($swaggerPath)) { + File::delete($swaggerPath); + } + } +}); + +// Test interactive grouping option selection +test('getGroupingOption returns provided option when set', function () { + $command = new \OPGG\LaravelMcpServer\Console\Commands\MakeSwaggerMcpToolCommand; + + // Mock the option method to return a value + $command = \Mockery::mock($command)->makePartial(); + $command->shouldReceive('option')->with('group-by')->andReturn('path'); + $command->shouldReceive('option')->with('no-interaction')->andReturn(false); + + $method = new ReflectionMethod($command, 'getGroupingOption'); + $method->setAccessible(true); + + $result = $method->invoke($command); + + expect($result)->toBe('path'); +}); + +test('getGroupingOption returns tag for non-interactive mode when no option provided', function () { + $command = new \OPGG\LaravelMcpServer\Console\Commands\MakeSwaggerMcpToolCommand; + + // Mock the option method to return null (no option provided) + $command = \Mockery::mock($command)->makePartial(); + $command->shouldReceive('option')->with('group-by')->andReturn(null); + $command->shouldReceive('option')->with('no-interaction')->andReturn(true); + + $method = new ReflectionMethod($command, 'getGroupingOption'); + $method->setAccessible(true); + + $result = $method->invoke($command); + + expect($result)->toBe('tag'); +}); + +test('getGroupingOption prompts user when no option and interactive mode', function () { + // Skip this test as it requires complex mocking of Laravel command internals + $this->markTestSkipped('Complex interactive mode testing requires full command initialization'); +}); + +test('getGroupingOption handles path selection in interactive mode', function () { + // Skip this test as it requires complex mocking of Laravel command internals + $this->markTestSkipped('Complex interactive mode testing requires full command initialization'); +}); + +test('getGroupingOption handles none selection in interactive mode', function () { + // Skip this test as it requires complex mocking of Laravel command internals + $this->markTestSkipped('Complex interactive mode testing requires full command initialization'); +}); + +// Test generateGroupingPreviews method +test('generateGroupingPreviews returns preview examples for all grouping options', function () { + $command = new \OPGG\LaravelMcpServer\Console\Commands\MakeSwaggerMcpToolCommand; + + // Mock the parser and converter + $mockParser = \Mockery::mock(\OPGG\LaravelMcpServer\Services\SwaggerParser\SwaggerParser::class); + $mockConverter = \Mockery::mock(\OPGG\LaravelMcpServer\Services\SwaggerParser\SwaggerToMcpConverter::class); + + // Sample endpoints for testing + $sampleEndpoints = [ + ['method' => 'GET', 'path' => '/pets', 'tags' => ['pet']], + ['method' => 'POST', 'path' => '/pets', 'tags' => ['pet']], + ['method' => 'GET', 'path' => '/users', 'tags' => ['user']], + ['method' => 'GET', 'path' => '/api/orders', 'tags' => ['order']], + ]; + + $mockParser->shouldReceive('getEndpoints')->andReturn($sampleEndpoints); + $mockConverter->shouldReceive('generateClassName')->andReturn('SampleTool'); + + // Use reflection to set the parser and converter properties + $parserProperty = new ReflectionProperty($command, 'parser'); + $parserProperty->setAccessible(true); + $parserProperty->setValue($command, $mockParser); + + $converterProperty = new ReflectionProperty($command, 'converter'); + $converterProperty->setAccessible(true); + $converterProperty->setValue($command, $mockConverter); + + $method = new ReflectionMethod($command, 'generateGroupingPreviews'); + $method->setAccessible(true); + + $result = $method->invoke($command); + + expect($result)->toBeArray(); + expect($result)->toHaveKey('tag'); + expect($result)->toHaveKey('path'); + expect($result)->toHaveKey('none'); + + // Check that 'none' has the default examples + expect($result['none'])->toContain('Tools/General/YourEndpointTool.php'); + expect($result['none'])->toContain('Resources/General/YourEndpointResource.php'); +}); + +test('generateGroupingPreviews handles endpoints with no tags', function () { + $command = new \OPGG\LaravelMcpServer\Console\Commands\MakeSwaggerMcpToolCommand; + + // Mock the parser and converter + $mockParser = \Mockery::mock(\OPGG\LaravelMcpServer\Services\SwaggerParser\SwaggerParser::class); + $mockConverter = \Mockery::mock(\OPGG\LaravelMcpServer\Services\SwaggerParser\SwaggerToMcpConverter::class); + + // Endpoints without tags + $sampleEndpoints = [ + ['method' => 'GET', 'path' => '/health', 'tags' => []], + ['method' => 'POST', 'path' => '/api/test'], + ]; + + $mockParser->shouldReceive('getEndpoints')->andReturn($sampleEndpoints); + $mockConverter->shouldReceive('generateClassName')->andReturn('HealthTool'); + + // Use reflection to set the parser and converter properties + $parserProperty = new ReflectionProperty($command, 'parser'); + $parserProperty->setAccessible(true); + $parserProperty->setValue($command, $mockParser); + + $converterProperty = new ReflectionProperty($command, 'converter'); + $converterProperty->setAccessible(true); + $converterProperty->setValue($command, $mockConverter); + + $method = new ReflectionMethod($command, 'generateGroupingPreviews'); + $method->setAccessible(true); + + $result = $method->invoke($command); + + expect($result)->toBeArray(); + expect($result['tag'])->toBeArray(); // Should still work, just might be empty + expect($result['path'])->toBeArray(); // Should have path-based examples +}); + +test('getGroupingOption displays previews in interactive mode', function () { + $command = \Mockery::mock(\OPGG\LaravelMcpServer\Console\Commands\MakeSwaggerMcpToolCommand::class)->makePartial()->shouldAllowMockingProtectedMethods(); + + // Mock the option method to return null (no group-by option provided) + $command->shouldReceive('option')->with('group-by')->andReturn(null); + $command->shouldReceive('option')->with('no-interaction')->andReturn(false); + + // Mock the preview generation + $mockPreviews = [ + 'tag' => ['Tools/Pet/FindPetsTool.php', 'Resources/User/GetUserResource.php'], + 'path' => ['Tools/Api/PostApiTool.php', 'Tools/Users/GetUsersTool.php'], + 'none' => ['Tools/General/YourEndpointTool.php', 'Resources/General/YourEndpointResource.php'], + ]; + + $command->shouldReceive('generateGroupingPreviews')->andReturn($mockPreviews); + + // Mock output methods + $command->shouldReceive('newLine')->andReturn(); + $command->shouldReceive('info')->with('πŸ—‚οΈ Choose how to organize your generated tools and resources:')->andReturn(); + $command->shouldReceive('line')->with(\Mockery::pattern('/.*<\/>/')); + $command->shouldReceive('line')->with(\Mockery::pattern('/πŸ“/')); + + // Mock choice method + $command->shouldReceive('choice') + ->with('Select grouping method', \Mockery::any(), 0) + ->andReturn('Tag-based grouping (organize by OpenAPI tags)'); + + $command->shouldReceive('info')->with(\Mockery::any())->andReturn(); + + $method = new ReflectionMethod($command, 'getGroupingOption'); + $method->setAccessible(true); + + $result = $method->invoke($command); + + expect($result)->toBe('tag'); +}); diff --git a/tests/Console/Commands/TagDirectoryEdgeCasesTest.php b/tests/Console/Commands/TagDirectoryEdgeCasesTest.php new file mode 100644 index 0000000..85d2c80 --- /dev/null +++ b/tests/Console/Commands/TagDirectoryEdgeCasesTest.php @@ -0,0 +1,249 @@ + [],\n 'resources' => [],\n];"; + File::put(config_path('mcp-server.php'), $configContent); +}); + +afterEach(function () { + // Clean up after each test + File::deleteDirectory(app_path('MCP/Tools')); + File::deleteDirectory(app_path('MCP/Resources')); + if (File::exists(config_path('mcp-server.php'))) { + File::delete(config_path('mcp-server.php')); + } +}); + +test('tag directory handles complex special characters', function () { + $filesystem = new \Illuminate\Filesystem\Filesystem; + $command = new \OPGG\LaravelMcpServer\Console\Commands\MakeSwaggerMcpToolCommand($filesystem); + + $method = new ReflectionMethod($command, 'createTagDirectory'); + $method->setAccessible(true); + + // Test various special character combinations + $testCases = [ + 'user-management-v2' => 'UserManagementV2', + 'api_v1_beta' => 'ApiV1Beta', + 'pet store' => 'PetStore', + 'user.profile' => 'UserProfile', + 'admin-panel_v2.0' => 'AdminPanelV20', + '123-api' => '123Api', + 'user@profile' => 'UserProfile', + 'api/v1/users' => 'ApiV1Users', + ]; + + foreach ($testCases as $input => $expected) { + $result = $method->invoke($command, ['tags' => [$input]]); + expect($result)->toBe($expected, "Failed for input: {$input}"); + } +}); + +test('tag directory handles empty strings and whitespace', function () { + $filesystem = new \Illuminate\Filesystem\Filesystem; + $command = new \OPGG\LaravelMcpServer\Console\Commands\MakeSwaggerMcpToolCommand($filesystem); + + $method = new ReflectionMethod($command, 'createTagDirectory'); + $method->setAccessible(true); + + $testCases = [ + '' => 'General', + ' ' => 'General', + "\t\n" => 'General', + ]; + + foreach ($testCases as $input => $expected) { + $result = $method->invoke($command, ['tags' => [$input]]); + expect($result)->toBe($expected, "Failed for input: {$input}"); + } +}); + +test('tag directory handles unicode characters', function () { + $filesystem = new \Illuminate\Filesystem\Filesystem; + $command = new \OPGG\LaravelMcpServer\Console\Commands\MakeSwaggerMcpToolCommand($filesystem); + + $method = new ReflectionMethod($command, 'createTagDirectory'); + $method->setAccessible(true); + + $testCases = [ + 'cafΓ©' => 'CafΓ©', + 'user_プロフゑむル' => 'Userプロフゑむル', + 'api-ζ΅‹θ―•' => 'Apiζ΅‹θ―•', + ]; + + foreach ($testCases as $input => $expected) { + $result = $method->invoke($command, ['tags' => [$input]]); + expect($result)->toBe($expected, "Failed for input: {$input}"); + } +}); + +test('tool and resource creation in same tag directory works correctly', function () { + // Simulate swagger generating both tool and resource with same tag + $filesystem = new \Illuminate\Filesystem\Filesystem; + $toolCommand = new \OPGG\LaravelMcpServer\Console\Commands\MakeMcpToolCommand($filesystem); + $resourceCommand = new \OPGG\LaravelMcpServer\Console\Commands\MakeMcpResourceCommand($filesystem); + + // Set up both commands with same tag directory + $toolProperty = new ReflectionProperty($toolCommand, 'dynamicParams'); + $toolProperty->setAccessible(true); + $toolProperty->setValue($toolCommand, ['tagDirectory' => 'Pet']); + + $resourceProperty = new ReflectionProperty($resourceCommand, 'dynamicParams'); + $resourceProperty->setAccessible(true); + $resourceProperty->setValue($resourceCommand, ['tagDirectory' => 'Pet']); + + // Test path generation + $toolMethod = new ReflectionMethod($toolCommand, 'getPath'); + $toolMethod->setAccessible(true); + $toolPath = $toolMethod->invoke($toolCommand, 'PetTool'); + + $resourceMethod = new ReflectionMethod($resourceCommand, 'getPath'); + $resourceMethod->setAccessible(true); + $resourcePath = $resourceMethod->invoke($resourceCommand, 'PetResource'); + + expect($toolPath)->toBe(app_path('MCP/Tools/Pet/PetTool.php')); + expect($resourcePath)->toBe(app_path('MCP/Resources/Pet/PetResource.php')); + + // Verify different base directories + expect(dirname(dirname($toolPath)))->toBe(app_path('MCP/Tools')); + expect(dirname(dirname($resourcePath)))->toBe(app_path('MCP/Resources')); +}); + +test('deeply nested tag directories work correctly', function () { + // Test creating very deep directory structures + $filesystem = new \Illuminate\Filesystem\Filesystem; + $toolCommand = new \OPGG\LaravelMcpServer\Console\Commands\MakeMcpToolCommand($filesystem); + + $property = new ReflectionProperty($toolCommand, 'dynamicParams'); + $property->setAccessible(true); + $property->setValue($toolCommand, ['tagDirectory' => 'VeryLongTagNameWithManyWords']); + + $method = new ReflectionMethod($toolCommand, 'getPath'); + $method->setAccessible(true); + + $result = $method->invoke($toolCommand, 'TestTool'); + $expected = app_path('MCP/Tools/VeryLongTagNameWithManyWords/TestTool.php'); + + expect($result)->toBe($expected); + + // Test directory creation + $makeDirectoryMethod = new ReflectionMethod($toolCommand, 'makeDirectory'); + $makeDirectoryMethod->setAccessible(true); + + $directoryResult = $makeDirectoryMethod->invoke($toolCommand, $expected); + expect(File::isDirectory($directoryResult))->toBeTrue(); +}); + +test('namespace collision prevention with different tags', function () { + // Test that tools with same name but different tags get different namespaces + $filesystem = new \Illuminate\Filesystem\Filesystem; + $toolCommand1 = new \OPGG\LaravelMcpServer\Console\Commands\MakeMcpToolCommand($filesystem); + $toolCommand2 = new \OPGG\LaravelMcpServer\Console\Commands\MakeMcpToolCommand($filesystem); + + // Set up different tag directories + $property1 = new ReflectionProperty($toolCommand1, 'dynamicParams'); + $property1->setAccessible(true); + $property1->setValue($toolCommand1, ['tagDirectory' => 'Pet']); + + $property2 = new ReflectionProperty($toolCommand2, 'dynamicParams'); + $property2->setAccessible(true); + $property2->setValue($toolCommand2, ['tagDirectory' => 'Store']); + + $method = new ReflectionMethod($toolCommand1, 'replaceStubPlaceholders'); + $method->setAccessible(true); + + $stub = 'namespace {{ namespace }}; class {{ className }} { }'; + $result1 = $method->invoke($toolCommand1, $stub, 'UpdateTool', 'update'); + $result2 = $method->invoke($toolCommand2, $stub, 'UpdateTool', 'update'); + + expect($result1)->toContain('namespace App\\MCP\\Tools\\Pet;'); + expect($result2)->toContain('namespace App\\MCP\\Tools\\Store;'); + + // Both contain same class name but different namespaces + expect($result1)->toContain('class UpdateTool'); + expect($result2)->toContain('class UpdateTool'); +}); + +test('swagger generation with mixed tagged and untagged endpoints', function () { + // Create swagger with mix of tagged and untagged endpoints + $swaggerData = [ + 'openapi' => '3.0.0', + 'info' => [ + 'title' => 'Mixed API', + 'version' => '1.0.0', + ], + 'paths' => [ + '/pet' => [ + 'post' => [ + 'tags' => ['pet'], + 'operationId' => 'addPet', + 'summary' => 'Add pet', + 'responses' => ['200' => ['description' => 'Success']], + ], + ], + '/health' => [ + 'post' => [ + // No tags + 'operationId' => 'healthCheck', + 'summary' => 'Health check', + 'responses' => ['200' => ['description' => 'Success']], + ], + ], + '/store/inventory' => [ + 'post' => [ + 'tags' => ['store', 'inventory'], // Multiple tags + 'operationId' => 'getInventory', + 'summary' => 'Get inventory', + 'responses' => ['200' => ['description' => 'Success']], + ], + ], + ], + ]; + + $swaggerPath = storage_path('swagger-mixed-test.json'); + File::put($swaggerPath, json_encode($swaggerData)); + + try { + $this->artisan('make:swagger-mcp-tool', [ + 'source' => $swaggerPath, + '--no-interaction' => true, + ]) + ->assertExitCode(0); + + // Check tagged endpoint goes to Pet directory + expect(File::exists(app_path('MCP/Tools/Pet/AddPetTool.php')))->toBeTrue(); + + // Check untagged endpoint goes to General directory + expect(File::exists(app_path('MCP/Tools/General/HealthCheckTool.php')))->toBeTrue(); + + // Check multi-tagged endpoint uses first tag (Store) + expect(File::exists(app_path('MCP/Tools/Store/GetInventoryTool.php')))->toBeTrue(); + + // Verify namespaces are correct + $petContent = File::get(app_path('MCP/Tools/Pet/AddPetTool.php')); + expect($petContent)->toContain('namespace App\\MCP\\Tools\\Pet;'); + + $healthContent = File::get(app_path('MCP/Tools/General/HealthCheckTool.php')); + expect($healthContent)->toContain('namespace App\\MCP\\Tools\\General;'); + + $storeContent = File::get(app_path('MCP/Tools/Store/GetInventoryTool.php')); + expect($storeContent)->toContain('namespace App\\MCP\\Tools\\Store;'); + + } finally { + if (File::exists($swaggerPath)) { + File::delete($swaggerPath); + } + } +});