diff --git a/README.md b/README.md index 1540322..f650a68 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,32 @@ ## ⚠️ Version Information & Breaking Changes -### v1.3.0 Changes (Current) +### v1.4.0 Changes (Latest) 🚀 + +Version 1.4.0 introduces powerful automatic tool and resource generation from Swagger/OpenAPI specifications: + +**New Features:** +- **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 + - 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 + - Complete Laravel HTTP client integration with retry logic + +**Example Usage:** +```bash +# Generate tools from OP.GG API +php artisan make:swagger-mcp-tool https://api.op.gg/lol/swagger.json + +# With options +php artisan make:swagger-mcp-tool ./api-spec.json --test-api --group-by=tag --prefix=MyApi +``` + +This feature dramatically reduces the time needed to integrate external APIs into your MCP server! + +### v1.3.0 Changes Version 1.3.0 introduces improvements to the `ToolInterface` for better communication control: @@ -310,6 +335,104 @@ This command: - Creates a properly structured tool class in `app/MCP/Tools` - Offers to automatically register the tool in your configuration +#### Generate Tools from Swagger/OpenAPI Specifications (v1.4.0+) + +Automatically generate MCP tools from any Swagger/OpenAPI specification with a single command: + +```bash +# From URL +php artisan make:swagger-mcp-tool https://api.example.com/swagger.json + +# From local file +php artisan make:swagger-mcp-tool ./specs/openapi.json + +# With options +php artisan make:swagger-mcp-tool https://api.example.com/swagger.json \ + --test-api \ + --group-by=tag \ + --prefix=MyApi +``` + +**Real-world Example with OP.GG API:** + +```bash +➜ php artisan make:swagger-mcp-tool https://api.op.gg/lol/swagger.json + +🚀 Swagger/OpenAPI to MCP Generator +========================================= +📄 Loading spec from: https://api.op.gg/lol/swagger.json +✅ Spec loaded successfully! ++-----------------+-------------------------+ +| Property | Value | ++-----------------+-------------------------+ +| Title | OP.GG Api Documentation | +| Version | openapi-3.0.0 | +| Base URL | https://api.op.gg | +| Total Endpoints | 6 | +| Tags | Riot | +| Security | | ++-----------------+-------------------------+ + +🎯 What would you like to generate from this API? + +Tools: For operations that perform actions (create, update, delete, compute) +Resources: For read-only data endpoints that provide information + +Generate as: + [0] Tools (for actions) + > 1 + [1] Resources (for read-only data) + > 1 + +✓ Will generate as MCP Resources + +Would you like to modify the base URL? Current: https://api.op.gg (yes/no) [no]: +> no + +📋 Select endpoints to generate resources for: +Note: Only GET endpoints can be converted to resources +Include tag: Riot (6 endpoints)? (yes/no) [yes]: +> yes + +Selected 6 endpoints. +🛠️ Generating MCP resources... +Note: operationId '5784a7dfd226e1621b0e6ee8c4f39407' looks like a hash, will use path-based naming +Generating: LolRegionRankingsGameTypeResource + ✅ Generated: LolRegionRankingsGameTypeResource +Generating: LolRegionServerStatsResource + ✅ Generated: LolRegionServerStatsResource +... + +📦 Generated 6 MCP resources: + - LolRegionRankingsGameTypeResource + - LolRegionServerStatsResource + - LolMetaChampionsResource + ... + +✅ MCP resources generated successfully! +``` + +**Key Features:** +- **Automatic API parsing**: Supports OpenAPI 3.x and Swagger 2.0 specifications +- **Dual generation modes**: + - **Tools**: For operations that perform actions (POST, PUT, DELETE, etc.) + - **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 +- **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 +- **Code generation**: Creates ready-to-use classes with Laravel HTTP client integration + +The generated tools include: +- Proper input validation based on API parameters +- Authentication headers configuration +- Error handling for API responses with JsonRpcErrorException +- Request retry logic (3 retries with 100ms delay) +- Query parameter, path parameter, and request body handling +- Laravel HTTP client with timeout configuration + You can also manually create and register tools in `config/mcp-server.php`: ```php diff --git a/src/Console/Commands/MakeMcpResourceCommand.php b/src/Console/Commands/MakeMcpResourceCommand.php index 487a143..5cd707d 100644 --- a/src/Console/Commands/MakeMcpResourceCommand.php +++ b/src/Console/Commands/MakeMcpResourceCommand.php @@ -8,39 +8,120 @@ class MakeMcpResourceCommand extends Command { - protected $signature = 'make:mcp-resource {name : The name of the resource}'; + /** + * The name and signature of the console command. + * + * @var string + */ + protected $signature = 'make:mcp-resource {name : The name of the resource} {--programmatic : Use programmatic mode with dynamic parameters}'; + /** + * The console command description. + * + * @var string + */ protected $description = 'Create a new MCP resource class'; - public function __construct(private Filesystem $files) + /** + * The filesystem instance. + * + * @var \Illuminate\Filesystem\Filesystem + */ + protected $files; + + /** + * Dynamic parameters for programmatic generation + */ + protected array $dynamicParams = []; + + /** + * Create a new command instance. + * + * @return void + */ + public function __construct(Filesystem $files) { parent::__construct(); + + $this->files = $files; } - public function handle(): int + /** + * Execute the console command. + * + * @return int + */ + public function handle() { $className = $this->getClassName(); $path = $this->getPath($className); + // Check if file already exists if ($this->files->exists($path)) { $this->error("❌ MCP resource {$className} already exists!"); return 1; } + // Create directories if they don't exist $this->makeDirectory($path); - $stub = $this->files->get(__DIR__.'/../../stubs/resource.stub'); - $stub = str_replace(['{{ className }}', '{{ namespace }}'], [$className, 'App\\MCP\\Resources'], $stub); - $this->files->put($path, $stub); + + // Generate the file using stub + $this->files->put($path, $this->buildClass($className)); + $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 + $this->registerResourceInConfig($fullClassName); + } + return 0; } - protected function getClassName(): string + /** + * Set dynamic parameters for programmatic generation + * + * @return $this + */ + public function setDynamicParams(array $params): self { - $name = preg_replace('/[\s\-_]+/', ' ', trim($this->argument('name'))); + $this->dynamicParams = $params; + + return $this; + } + + /** + * Get the class name from the command argument. + * + * @return string + */ + protected function getClassName() + { + $name = $this->argument('name'); + + // Clean up the input: remove multiple spaces, hyphens, underscores + // and handle mixed case input + $name = preg_replace('/[\s\-_]+/', ' ', trim($name)); + + // Convert to StudlyCase $name = Str::studly($name); + + // Ensure the class name ends with "Resource" if not already if (! Str::endsWith($name, 'Resource')) { $name .= 'Resource'; } @@ -48,16 +129,203 @@ protected function getClassName(): string return $name; } - protected function getPath(string $className): string + /** + * Get the destination file path. + * + * @return string + */ + protected function getPath(string $className) { + // Create the file in the app/MCP/Resources directory return app_path("MCP/Resources/{$className}.php"); } - protected function makeDirectory(string $path): void + /** + * Build the directory for the class if necessary. + * + * @param string $path + * @return string + */ + protected function makeDirectory($path) { - $dir = dirname($path); - if (! $this->files->isDirectory($dir)) { - $this->files->makeDirectory($dir, 0755, true, true); + $directory = dirname($path); + + if (! $this->files->isDirectory($directory)) { + $this->files->makeDirectory($directory, 0755, true, true); + } + + return $directory; + } + + /** + * Build the class with the given name. + * + * @return string + */ + protected function buildClass(string $className) + { + // Use dynamic stub if in programmatic mode + if ($this->option('programmatic') && ! empty($this->dynamicParams)) { + return $this->buildDynamicClass($className); + } + + $stub = $this->files->get($this->getStubPath()); + + return $this->replaceStubPlaceholders($stub, $className); + } + + /** + * Build a class with dynamic parameters + */ + protected function buildDynamicClass(string $className): string + { + // Load the programmatic stub + $stub = $this->files->get(__DIR__.'/../../stubs/resource.programmatic.stub'); + + $uri = $this->dynamicParams['uri'] ?? 'api://resource'; + $name = $this->dynamicParams['name'] ?? $className; + $description = $this->dynamicParams['description'] ?? "Resource for {$className}"; + $mimeType = $this->dynamicParams['mimeType'] ?? 'application/json'; + $readLogic = $this->dynamicParams['readLogic'] ?? $this->getDefaultReadLogic(); + + // Replace placeholders in stub + $replacements = [ + '{{ namespace }}' => 'App\\MCP\\Resources', + '{{ className }}' => $className, + '{{ uri }}' => $uri, + '{{ name }}' => addslashes($name), + '{{ description }}' => addslashes($description), + '{{ mimeType }}' => $mimeType, + '{{ readLogic }}' => $readLogic, + ]; + + foreach ($replacements as $search => $replace) { + $stub = str_replace($search, $replace, $stub); + } + + return $stub; + } + + /** + * Get default read logic for the resource + */ + protected function getDefaultReadLogic(): string + { + return <<<'PHP' + try { + // TODO: Implement your resource reading logic here + $data = [ + 'message' => 'Resource data placeholder', + 'timestamp' => now()->toISOString(), + ]; + + return [ + 'uri' => $this->uri, + 'mimeType' => $this->mimeType, + 'text' => json_encode($data, JSON_PRETTY_PRINT), + ]; + } catch (\Exception $e) { + throw new \RuntimeException( + "Failed to read resource {$this->uri}: " . $e->getMessage() + ); + } +PHP; + } + + /** + * Get the stub file path. + * + * @return string + */ + protected function getStubPath() + { + return __DIR__.'/../../stubs/resource.stub'; + } + + /** + * Replace the stub placeholders with actual values. + * + * @return string + */ + protected function replaceStubPlaceholders(string $stub, string $className) + { + return str_replace( + ['{{ className }}', '{{ namespace }}'], + [$className, 'App\\MCP\\Resources'], + $stub + ); + } + + /** + * Register the resource in the MCP server configuration file. + * + * @param string $resourceClassName Fully qualified class name of the resource + * @return bool Whether registration was successful + */ + protected function registerResourceInConfig(string $resourceClassName): bool + { + $configPath = config_path('mcp-server.php'); + + if (! file_exists($configPath)) { + $this->error("❌ Config file not found: {$configPath}"); + + return false; + } + + $content = file_get_contents($configPath); + + // Find the resources array in the config file + if (! preg_match('/[\'"]resources[\'\"]\s*=>\s*\[([^\]]*)\]/s', $content, $matches)) { + // Try to add resources array after tools array if it doesn't exist + if (preg_match('/([\'"]tools[\'\"]\s*=>\s*\[.*?\s*\],)/s', $content, $toolsMatches)) { + $toolsArray = $toolsMatches[1]; + $resourcesArray = "\n\n // Resources - Static resources that expose data to LLMs\n 'resources' => [\n {$resourceClassName}::class,\n ],"; + $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'); + + return true; + } else { + $this->error('❌ Failed to update config file. Please manually register the resource.'); + + return false; + } + } + + $this->error('❌ Could not locate resources array in config file.'); + + return false; + } + + $resourcesArrayContent = $matches[1]; + + // Check if the resource is already registered + if (strpos($resourcesArrayContent, $resourceClassName) !== false) { + $this->info('✅ Resource is already registered in config file.'); + + return true; + } + + // Handle empty array case + $fullEntry = trim($resourcesArrayContent) === '' + ? "\n {$resourceClassName}::class,\n " + : "\n {$resourceClassName}::class,"; + + // Replace the entire resources array content + $oldResourcesArray = "[{$resourcesArrayContent}]"; + $newResourcesArray = "[{$resourcesArrayContent}{$fullEntry}]"; + $newContent = str_replace($oldResourcesArray, $newResourcesArray, $content); + + // 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'); + + return true; + } else { + $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 c44f2d1..c868633 100644 --- a/src/Console/Commands/MakeMcpToolCommand.php +++ b/src/Console/Commands/MakeMcpToolCommand.php @@ -13,7 +13,7 @@ class MakeMcpToolCommand extends Command * * @var string */ - protected $signature = 'make:mcp-tool {name : The name of the MCP tool}'; + protected $signature = 'make:mcp-tool {name : The name of the MCP tool} {--programmatic : Use programmatic mode with dynamic parameters}'; /** * The console command description. @@ -41,6 +41,11 @@ public function __construct(Filesystem $files) $this->files = $files; } + /** + * Dynamic parameters for programmatic generation + */ + protected array $dynamicParams = []; + /** * Execute the console command. * @@ -68,28 +73,45 @@ public function handle() $fullClassName = "\\App\\MCP\\Tools\\{$className}"; - // Ask if they want to automatically register the tool - if ($this->confirm('🤖 Would you like to automatically register this tool in config/mcp-server.php?', true)) { - $this->registerToolInConfig($fullClassName); + // 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(' ],'); + } + + // Display testing instructions + $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 { - $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(' ],'); + // In programmatic mode, always register the tool + $this->registerToolInConfig($fullClassName); } - // Display testing instructions - $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'); - return 0; } + /** + * Set dynamic parameters for programmatic generation + * + * @return $this + */ + public function setDynamicParams(array $params): self + { + $this->dynamicParams = $params; + + return $this; + } + /** * Get the class name from the command argument. * @@ -149,6 +171,11 @@ protected function makeDirectory($path) */ protected function buildClass(string $className) { + // Use dynamic stub if in programmatic mode + if ($this->option('programmatic') && ! empty($this->dynamicParams)) { + return $this->buildDynamicClass($className); + } + $stub = $this->files->get($this->getStubPath()); // Generate a kebab-case tool name without the 'Tool' suffix @@ -168,6 +195,84 @@ protected function buildClass(string $className) return $this->replaceStubPlaceholders($stub, $className, $toolName); } + /** + * Build a class with dynamic parameters + */ + protected function buildDynamicClass(string $className): string + { + // Load the programmatic stub + $stub = $this->files->get(__DIR__.'/../../stubs/tool.programmatic.stub'); + + $params = $this->dynamicParams; + + // Extract parameters + $toolName = $params['toolName'] ?? Str::kebab(preg_replace('/Tool$/', '', $className)); + $description = $params['description'] ?? 'Auto-generated MCP tool'; + $inputSchema = $params['inputSchema'] ?? []; + $annotations = $params['annotations'] ?? []; + $executeLogic = $params['executeLogic'] ?? ' return ["result" => "success"];'; + $imports = $params['imports'] ?? []; + + // Build imports + $importsString = ''; + foreach ($imports as $import) { + $importsString .= "use {$import};\n"; + } + + // Build input schema + $inputSchemaString = $this->arrayToPhpString($inputSchema, 2); + + // Build annotations + $annotationsString = $this->arrayToPhpString($annotations, 2); + + // Replace placeholders in stub + $replacements = [ + '{{ namespace }}' => 'App\\MCP\\Tools', + '{{ className }}' => $className, + '{{ toolName }}' => $toolName, + '{{ description }}' => addslashes($description), + '{{ inputSchema }}' => $inputSchemaString, + '{{ annotations }}' => $annotationsString, + '{{ executeLogic }}' => $executeLogic, + '{{ imports }}' => $importsString, + ]; + + foreach ($replacements as $search => $replace) { + $stub = str_replace($search, $replace, $stub); + } + + return $stub; + } + + /** + * Convert array to PHP string representation + */ + protected function arrayToPhpString(array $array, int $indent = 0): string + { + if (empty($array)) { + return '[]'; + } + + $json = json_encode($array, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + + // Convert JSON to PHP array syntax + $php = preg_replace('/^(\s*)"([^"]+)"\s*:/m', '$1\'$2\' =>', $json); + $php = str_replace('{', '[', $php); + $php = str_replace('}', ']', $php); + $php = preg_replace('/: true/', '=> true', $php); + $php = preg_replace('/: false/', '=> false', $php); + $php = preg_replace('/: null/', '=> null', $php); + $php = preg_replace('/: (\d+)/', '=> $1', $php); + $php = preg_replace('/: (\d+\.\d+)/', '=> $1', $php); + + // Add indentation + $lines = explode("\n", $php); + $indentStr = str_repeat(' ', $indent); + $php = implode("\n".$indentStr, $lines); + + return $php; + } + /** * Get the stub file path. * diff --git a/src/Console/Commands/MakeSwaggerMcpToolCommand.php b/src/Console/Commands/MakeSwaggerMcpToolCommand.php new file mode 100644 index 0000000..bae150a --- /dev/null +++ b/src/Console/Commands/MakeSwaggerMcpToolCommand.php @@ -0,0 +1,692 @@ +info('🚀 Swagger/OpenAPI to MCP Generator'); + $this->line('========================================='); + + try { + // Step 1: Load and validate spec + $this->loadSpec(); + + // Step 2: Test API connection (optional) + if ($this->option('test-api') && ! $this->option('no-interaction')) { + $this->testApiConnection(); + } + + // Step 3: Select endpoints and their types + $this->selectEndpointsWithTypes(); + + // Step 4: Configure authentication + if (! $this->option('no-interaction')) { + $this->configureAuthentication(); + } + + // Step 5: Generate tools and resources + $this->generateComponents(); + + return 0; + + } catch (\Exception $e) { + $this->error('❌ Error: '.$e->getMessage()); + + return 1; + } + } + + /** + * Select endpoints and their generation types + */ + protected function selectEndpointsWithTypes(): void + { + $endpoints = $this->parser->getEndpoints(); + + if ($this->option('no-interaction')) { + // In non-interactive mode, use smart defaults + foreach ($endpoints as $endpoint) { + if ($endpoint['deprecated']) { + continue; + } + + // GET endpoints become resources, others become tools + $type = $endpoint['method'] === 'GET' ? 'resource' : 'tool'; + $this->selectedEndpointsWithType[] = [ + 'endpoint' => $endpoint, + 'type' => $type, + ]; + } + + $toolCount = count(array_filter($this->selectedEndpointsWithType, fn ($e) => $e['type'] === 'tool')); + $resourceCount = count(array_filter($this->selectedEndpointsWithType, fn ($e) => $e['type'] === 'resource')); + + $this->info("Selected {$toolCount} tools and {$resourceCount} resources (excluding deprecated)."); + + return; + } + + $this->info('📋 Select endpoints and choose their generation type:'); + $this->newLine(); + $this->comment('Tip: GET endpoints are typically Resources, while POST/PUT/DELETE are Tools'); + $this->newLine(); + + $groupBy = $this->option('group-by'); + + if ($groupBy === 'tag') { + $this->selectByTagWithTypes(); + } elseif ($groupBy === 'path') { + $this->selectByPathWithTypes(); + } else { + $this->selectIndividuallyWithTypes(); + } + + $toolCount = count(array_filter($this->selectedEndpointsWithType, fn ($e) => $e['type'] === 'tool')); + $resourceCount = count(array_filter($this->selectedEndpointsWithType, fn ($e) => $e['type'] === 'resource')); + + $this->info("Selected {$toolCount} tools and {$resourceCount} resources."); + } + + /** + * Load and validate the Swagger/OpenAPI spec + */ + protected function loadSpec(): void + { + $source = $this->argument('source'); + + $this->info("📄 Loading spec from: {$source}"); + + $this->parser = new SwaggerParser; + $this->parser->load($source); + + $info = $this->parser->getInfo(); + + $this->info('✅ Spec loaded successfully!'); + $this->table( + ['Property', 'Value'], + [ + ['Title', $info['title']], + ['Version', $info['version']], + ['Base URL', $info['baseUrl'] ?? 'Not specified'], + ['Total Endpoints', $info['totalEndpoints']], + ['Tags', implode(', ', $info['tags'])], + ['Security', implode(', ', $info['securitySchemes'])], + ] + ); + + // Ask to modify base URL if needed + if (! $this->option('no-interaction') && $info['baseUrl']) { + if ($this->confirm("Would you like to modify the base URL? Current: {$info['baseUrl']}", false)) { + $newUrl = $this->ask('Enter new base URL'); + $this->parser->setBaseUrl($newUrl); + } + } + + $this->converter = new SwaggerToMcpConverter($this->parser); + } + + /** + * Test API connection + */ + protected function testApiConnection(): void + { + $this->info('🔧 Testing API connection...'); + + $baseUrl = $this->parser->getBaseUrl(); + if (! $baseUrl) { + $this->warn('No base URL found. Skipping API test.'); + + return; + } + + // Find a simple GET endpoint to test + $testEndpoint = null; + foreach ($this->parser->getEndpoints() as $endpoint) { + if ($endpoint['method'] === 'GET' && empty($endpoint['parameters'])) { + $testEndpoint = $endpoint; + break; + } + } + + if (! $testEndpoint) { + // Try any GET endpoint + foreach ($this->parser->getEndpoints() as $endpoint) { + if ($endpoint['method'] === 'GET') { + $testEndpoint = $endpoint; + break; + } + } + } + + if ($testEndpoint) { + $url = $baseUrl.$testEndpoint['path']; + $this->line("Testing: GET {$url}"); + + try { + $response = Http::timeout(10)->get($url); + + if ($response->successful()) { + $this->info("✅ API is accessible! Status: {$response->status()}"); + } else { + $this->warn("⚠️ API returned status {$response->status()}. This might be normal if authentication is required."); + } + } catch (\Exception $e) { + $this->warn('⚠️ Could not connect to API: '.$e->getMessage()); + + if ($this->confirm('Would you like to continue anyway?', true)) { + return; + } + + throw $e; + } + } else { + $this->warn('No suitable endpoint found for testing.'); + } + } + + /** + * Select endpoints individually with type choice + */ + protected function selectIndividuallyWithTypes(): 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'])) { + // Ask for type + $defaultType = $endpoint['method'] === 'GET' ? 'Resource' : 'Tool'; + $typeChoice = $this->choice( + 'Generate as', + ['Tool (for actions)', 'Resource (for read-only data)', 'Skip'], + $endpoint['method'] === 'GET' ? 1 : 0 + ); + + if (! str_contains($typeChoice, 'Skip')) { + $type = str_contains($typeChoice, 'Tool') ? 'tool' : 'resource'; + + // Validate: only GET can be resources + if ($type === 'resource' && $endpoint['method'] !== 'GET') { + $this->warn('Only GET endpoints can be resources. Generating as Tool instead.'); + $type = 'tool'; + } + + $this->selectedEndpointsWithType[] = [ + 'endpoint' => $endpoint, + 'type' => $type, + ]; + } + } + } + } + + /** + * Select endpoints by tag with type choice + */ + protected function selectByTagWithTypes(): 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)) { + continue; + } + + // Smart default based on method + $defaultType = $endpoint['method'] === 'GET' ? 'resource' : 'tool'; + + $endpointLabel = "{$endpoint['method']} {$endpoint['path']}"; + if ($endpoint['summary']) { + $endpointLabel .= " - {$endpoint['summary']}"; + } + + $this->info($endpointLabel); + $typeChoice = $this->choice( + 'Generate as', + ['Tool', 'Resource', 'Skip'], + 0 + ); + + if ($typeChoice !== 'Skip') { + $type = strtolower($typeChoice); + + // Validate: only GET can be resources + if ($type === 'resource' && $endpoint['method'] !== 'GET') { + $this->warn('Only GET endpoints can be resources. Generating as Tool instead.'); + $type = 'tool'; + } + + $this->selectedEndpointsWithType[] = [ + 'endpoint' => $endpoint, + 'type' => $type, + ]; + } + } + } + } + } + + /** + * Select endpoints by path with type choice + */ + protected function selectByPathWithTypes(): 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)) { + continue; + } + + $endpointLabel = "{$endpoint['method']} {$endpoint['path']}"; + if ($endpoint['summary']) { + $endpointLabel .= " - {$endpoint['summary']}"; + } + + $this->info($endpointLabel); + $typeChoice = $this->choice( + 'Generate as', + ['Tool', 'Resource', 'Skip'], + $endpoint['method'] === 'GET' ? 1 : 0 + ); + + if ($typeChoice !== 'Skip') { + $type = strtolower($typeChoice); + + // Validate: only GET can be resources + if ($type === 'resource' && $endpoint['method'] !== 'GET') { + $this->warn('Only GET endpoints can be resources. Generating as Tool instead.'); + $type = 'tool'; + } + + $this->selectedEndpointsWithType[] = [ + 'endpoint' => $endpoint, + 'type' => $type, + ]; + } + } + } + } + } + + /** + * 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 + */ + protected function configureAuthentication(): void + { + $schemes = $this->parser->getSecuritySchemes(); + + if (empty($schemes)) { + $this->info('No security schemes found in spec.'); + + return; + } + + $this->info('🔐 Configure authentication:'); + + foreach ($schemes as $name => $scheme) { + $type = $scheme['type'] ?? 'unknown'; + + $this->line("Security scheme: {$name} (type: {$type})"); + + if ($type === 'apiKey') { + $in = $scheme['in'] ?? 'header'; + $paramName = $scheme['name'] ?? 'X-API-Key'; + + if ($this->confirm('Configure API Key authentication?', true)) { + $this->authConfig['api_key'] = [ + 'location' => $in, + 'name' => $paramName, + ]; + + $this->info("API Key will be read from config('services.api.key')"); + $this->line('Add to your .env: API_KEY=your-key-here'); + } + } elseif ($type === 'http' && ($scheme['scheme'] ?? '') === 'bearer') { + if ($this->confirm('Configure Bearer Token authentication?', true)) { + $this->authConfig['bearer_token'] = true; + + $this->info("Bearer token will be read from config('services.api.token')"); + $this->line('Add to your .env: API_TOKEN=your-token-here'); + } + } elseif ($type === 'oauth2') { + $this->warn('OAuth2 authentication detected. Manual configuration will be needed in generated tools.'); + } + } + + if (! empty($this->authConfig)) { + $this->converter->setAuthConfig($this->authConfig); + } + } + + /** + * Generate both tools and resources based on selected endpoints + */ + protected function generateComponents(): void + { + $this->info('🛠️ Generating MCP components...'); + + $prefix = $this->option('prefix'); + $generatedTools = []; + $generatedResources = []; + + foreach ($this->selectedEndpointsWithType as $item) { + $endpoint = $item['endpoint']; + $type = $item['type']; + + // Check if operationId looks like a hash + if (! empty($endpoint['operationId']) && preg_match('/^[a-f0-9]{32}$/i', $endpoint['operationId'])) { + $this->comment("Note: operationId '{$endpoint['operationId']}' looks like a hash, will use path-based naming"); + $endpoint['operationId'] = null; + } + + if ($type === 'tool') { + // Generate tool + $className = $this->converter->generateClassName($endpoint, $prefix); + $path = app_path("MCP/Tools/{$className}.php"); + + if (file_exists($path)) { + $this->warn("Skipping {$className} - already exists"); + + continue; + } + + $this->line("Generating Tool: {$className}"); + + // Get tool parameters + $toolParams = $this->converter->convertEndpointToTool($endpoint, $className); + + // Create the tool using MakeMcpToolCommand + $makeTool = new MakeMcpToolCommand(app('files')); + $makeTool->setLaravel($this->getLaravel()); + $makeTool->setDynamicParams($toolParams); + + $input = new \Symfony\Component\Console\Input\ArrayInput([ + 'name' => $className, + '--programmatic' => true, + ]); + + $output = new \Symfony\Component\Console\Output\NullOutput; + + try { + $makeTool->run($input, $output); + $generatedTools[] = $className; + $this->info(" ✅ Generated Tool: {$className}"); + } catch (\Exception $e) { + $this->error(" ❌ Failed to generate {$className}: ".$e->getMessage()); + } + + } else { + // Generate resource + $className = $this->converter->generateResourceClassName($endpoint, $prefix); + $path = app_path("MCP/Resources/{$className}.php"); + + if (file_exists($path)) { + $this->warn("Skipping {$className} - already exists"); + + continue; + } + + $this->line("Generating Resource: {$className}"); + + // Get resource parameters + $resourceParams = $this->converter->convertEndpointToResource($endpoint, $className); + + // Create the resource using MakeMcpResourceCommand + $makeResource = new MakeMcpResourceCommand(app('files')); + $makeResource->setLaravel($this->getLaravel()); + $makeResource->setDynamicParams($resourceParams); + + $input = new \Symfony\Component\Console\Input\ArrayInput([ + 'name' => $className, + '--programmatic' => true, + ]); + + $output = new \Symfony\Component\Console\Output\NullOutput; + + try { + $makeResource->run($input, $output); + $generatedResources[] = $className; + $this->info(" ✅ Generated Resource: {$className}"); + } catch (\Exception $e) { + $this->error(" ❌ Failed to generate {$className}: ".$e->getMessage()); + } + } + } + + // Show summary + $this->newLine(); + + if (! empty($generatedTools)) { + $this->info('📦 Generated '.count($generatedTools).' MCP tools:'); + foreach ($generatedTools as $className) { + $this->line(" - {$className}"); + } + } + + if (! empty($generatedResources)) { + $this->info('📦 Generated '.count($generatedResources).' MCP resources:'); + foreach ($generatedResources as $className) { + $this->line(" - {$className}"); + } + } + + if (empty($generatedTools) && empty($generatedResources)) { + $this->warn('No components were generated.'); + } else { + $this->newLine(); + $this->info('✅ MCP components generated successfully!'); + $this->newLine(); + $this->info('Next steps:'); + + $stepNumber = 1; + + if (! empty($generatedTools)) { + $this->line("{$stepNumber}. Review generated tools in app/MCP/Tools/"); + $stepNumber++; + $this->line("{$stepNumber}. Test tools with: php artisan mcp:test-tool "); + $stepNumber++; + } + + if (! empty($generatedResources)) { + $this->line("{$stepNumber}. Review generated resources in app/MCP/Resources/"); + $stepNumber++; + $this->line("{$stepNumber}. Test resources with the MCP Inspector or client"); + $stepNumber++; + } + + $this->line("{$stepNumber}. All components have been automatically registered in config/mcp-server.php"); + $stepNumber++; + $this->line("{$stepNumber}. Update authentication configuration if needed"); + } + } +} diff --git a/src/LaravelMcpServerServiceProvider.php b/src/LaravelMcpServerServiceProvider.php index a8b399c..ebf63bf 100644 --- a/src/LaravelMcpServerServiceProvider.php +++ b/src/LaravelMcpServerServiceProvider.php @@ -9,6 +9,7 @@ use OPGG\LaravelMcpServer\Console\Commands\MakeMcpResourceCommand; use OPGG\LaravelMcpServer\Console\Commands\MakeMcpResourceTemplateCommand; use OPGG\LaravelMcpServer\Console\Commands\MakeMcpToolCommand; +use OPGG\LaravelMcpServer\Console\Commands\MakeSwaggerMcpToolCommand; use OPGG\LaravelMcpServer\Console\Commands\MigrateToolsCommand; use OPGG\LaravelMcpServer\Console\Commands\TestMcpToolCommand; use OPGG\LaravelMcpServer\Http\Controllers\MessageController; @@ -38,6 +39,7 @@ public function configurePackage(Package $package): void MakeMcpResourceTemplateCommand::class, MakeMcpPromptCommand::class, MakeMcpNotificationCommand::class, + MakeSwaggerMcpToolCommand::class, TestMcpToolCommand::class, MigrateToolsCommand::class, ]); diff --git a/src/Services/SwaggerParser/SwaggerParser.php b/src/Services/SwaggerParser/SwaggerParser.php new file mode 100644 index 0000000..6b77cbf --- /dev/null +++ b/src/Services/SwaggerParser/SwaggerParser.php @@ -0,0 +1,407 @@ +isValidUrl($source)) { + $this->loadFromUrl($source); + } else { + $this->loadFromFile($source); + } + + $this->detectVersion(); + $this->parseSpec(); + + return $this; + } + + /** + * Load spec from URL + */ + protected function loadFromUrl(string $url): void + { + $response = Http::get($url); + + if (! $response->successful()) { + throw new \Exception("Failed to load Swagger spec from URL: {$url}"); + } + + $contentType = $response->header('Content-Type'); + + if (Str::contains($contentType, 'yaml') || Str::endsWith($url, ['.yaml', '.yml'])) { + // For YAML support, we'll need symfony/yaml package + throw new \Exception('YAML format not yet supported. Please use JSON format.'); + } + + $this->spec = $response->json(); + + if (! is_array($this->spec)) { + throw new \Exception('Invalid Swagger/OpenAPI spec format'); + } + } + + /** + * Load spec from file + */ + protected function loadFromFile(string $path): void + { + if (! file_exists($path)) { + throw new \Exception("Swagger spec file not found: {$path}"); + } + + $content = file_get_contents($path); + + if (Str::endsWith($path, ['.yaml', '.yml'])) { + // For YAML support, we'll need symfony/yaml package + throw new \Exception('YAML format not yet supported. Please use JSON format.'); + } + + $this->spec = json_decode($content, true); + + if (! is_array($this->spec)) { + throw new \Exception('Invalid Swagger/OpenAPI spec format'); + } + } + + /** + * Detect Swagger/OpenAPI version + */ + protected function detectVersion(): void + { + if (isset($this->spec['openapi'])) { + $this->version = 'openapi-'.$this->spec['openapi']; + } elseif (isset($this->spec['swagger'])) { + $this->version = 'swagger-'.$this->spec['swagger']; + } else { + throw new \Exception('Could not detect Swagger/OpenAPI version'); + } + } + + /** + * Parse the spec to extract relevant information + */ + protected function parseSpec(): void + { + // Extract base URL + $this->extractBaseUrl(); + + // Extract security schemes + $this->extractSecuritySchemes(); + + // Extract endpoints + $this->extractEndpoints(); + } + + /** + * Extract base URL from spec + */ + protected function extractBaseUrl(): void + { + if (Str::startsWith($this->version, 'openapi-')) { + // OpenAPI 3.x + if (isset($this->spec['servers'][0]['url'])) { + $this->baseUrl = $this->spec['servers'][0]['url']; + } + } else { + // Swagger 2.0 + $scheme = $this->spec['schemes'][0] ?? 'https'; + $host = $this->spec['host'] ?? ''; + $basePath = $this->spec['basePath'] ?? ''; + + if ($host) { + $this->baseUrl = "{$scheme}://{$host}{$basePath}"; + } + } + } + + /** + * Extract security schemes + */ + protected function extractSecuritySchemes(): void + { + if (Str::startsWith($this->version, 'openapi-')) { + // OpenAPI 3.x + $this->securitySchemes = $this->spec['components']['securitySchemes'] ?? []; + } else { + // Swagger 2.0 + $this->securitySchemes = $this->spec['securityDefinitions'] ?? []; + } + } + + /** + * Extract endpoints from paths + */ + protected function extractEndpoints(): void + { + $paths = $this->spec['paths'] ?? []; + + foreach ($paths as $path => $methods) { + foreach ($methods as $method => $operation) { + // Skip non-HTTP methods + if (! in_array($method, ['get', 'post', 'put', 'patch', 'delete', 'head', 'options'])) { + continue; + } + + $endpoint = [ + 'path' => $path, + 'method' => strtoupper($method), + 'operationId' => $operation['operationId'] ?? null, + 'summary' => $operation['summary'] ?? '', + 'description' => $operation['description'] ?? '', + 'tags' => $operation['tags'] ?? [], + 'deprecated' => $operation['deprecated'] ?? false, + 'parameters' => $this->extractParameters($operation, $path), + 'requestBody' => $this->extractRequestBody($operation), + 'responses' => $this->extractResponses($operation), + 'security' => $operation['security'] ?? $this->spec['security'] ?? [], + ]; + + $this->endpoints[] = $endpoint; + } + } + } + + /** + * Extract parameters from operation + */ + protected function extractParameters(array $operation, string $path): array + { + $parameters = []; + + // Get path-level parameters if any + $pathItem = $this->spec['paths'][$path] ?? []; + $pathParams = $pathItem['parameters'] ?? []; + + // Get operation-level parameters + $operationParams = $operation['parameters'] ?? []; + + // Merge parameters (operation overrides path) + $allParams = array_merge($pathParams, $operationParams); + + foreach ($allParams as $param) { + // Handle $ref + if (isset($param['$ref'])) { + $param = $this->resolveReference($param['$ref']); + } + + $parameters[] = [ + 'name' => $param['name'] ?? '', + 'in' => $param['in'] ?? 'query', // path, query, header, cookie + 'description' => $param['description'] ?? '', + 'required' => $param['required'] ?? false, + 'schema' => $param['schema'] ?? $param, // OpenAPI 3.x vs Swagger 2.0 + 'type' => $param['type'] ?? $param['schema']['type'] ?? 'string', + ]; + } + + return $parameters; + } + + /** + * Extract request body (OpenAPI 3.x) + */ + protected function extractRequestBody(array $operation): ?array + { + if (! isset($operation['requestBody'])) { + // Check for Swagger 2.0 body parameters + foreach ($operation['parameters'] ?? [] as $param) { + if (($param['in'] ?? '') === 'body') { + return [ + 'required' => $param['required'] ?? false, + 'schema' => $param['schema'] ?? [], + ]; + } + } + + return null; + } + + $requestBody = $operation['requestBody']; + + // Handle $ref + if (isset($requestBody['$ref'])) { + $requestBody = $this->resolveReference($requestBody['$ref']); + } + + return [ + 'required' => $requestBody['required'] ?? false, + 'content' => $requestBody['content'] ?? [], + 'description' => $requestBody['description'] ?? '', + ]; + } + + /** + * Extract responses + */ + protected function extractResponses(array $operation): array + { + $responses = []; + + foreach ($operation['responses'] ?? [] as $code => $response) { + // Handle $ref + if (isset($response['$ref'])) { + $response = $this->resolveReference($response['$ref']); + } + + $responses[$code] = [ + 'description' => $response['description'] ?? '', + 'content' => $response['content'] ?? [], + 'schema' => $response['schema'] ?? null, // Swagger 2.0 + ]; + } + + return $responses; + } + + /** + * Resolve $ref references + */ + protected function resolveReference(string $ref): array + { + // Remove '#/' prefix + $ref = str_replace('#/', '', $ref); + + // Split by / + $parts = explode('/', $ref); + + // Navigate through spec + $resolved = $this->spec; + foreach ($parts as $part) { + $resolved = $resolved[$part] ?? []; + } + + return $resolved; + } + + /** + * Get all endpoints + */ + public function getEndpoints(): array + { + return $this->endpoints; + } + + /** + * Get endpoints grouped by tag + */ + public function getEndpointsByTag(): array + { + $grouped = []; + + foreach ($this->endpoints as $endpoint) { + $tags = $endpoint['tags'] ?: ['default']; + + foreach ($tags as $tag) { + if (! isset($grouped[$tag])) { + $grouped[$tag] = []; + } + $grouped[$tag][] = $endpoint; + } + } + + return $grouped; + } + + /** + * Get spec info + */ + public function getInfo(): array + { + return [ + 'version' => $this->version, + 'title' => $this->spec['info']['title'] ?? 'Unknown', + 'description' => $this->spec['info']['description'] ?? '', + 'baseUrl' => $this->baseUrl, + 'securitySchemes' => array_keys($this->securitySchemes), + 'totalEndpoints' => count($this->endpoints), + 'tags' => $this->getTags(), + ]; + } + + /** + * Get all tags + */ + public function getTags(): array + { + $tags = []; + + foreach ($this->endpoints as $endpoint) { + foreach ($endpoint['tags'] as $tag) { + $tags[$tag] = true; + } + } + + return array_keys($tags); + } + + /** + * Get security schemes + */ + public function getSecuritySchemes(): array + { + return $this->securitySchemes; + } + + /** + * Get base URL + */ + public function getBaseUrl(): ?string + { + return $this->baseUrl; + } + + /** + * Set base URL + */ + public function setBaseUrl(string $url): self + { + $this->baseUrl = rtrim($url, '/'); + + return $this; + } +} diff --git a/src/Services/SwaggerParser/SwaggerToMcpConverter.php b/src/Services/SwaggerParser/SwaggerToMcpConverter.php new file mode 100644 index 0000000..c501791 --- /dev/null +++ b/src/Services/SwaggerParser/SwaggerToMcpConverter.php @@ -0,0 +1,753 @@ +parser = $parser; + } + + /** + * Set authentication configuration + */ + public function setAuthConfig(array $config): self + { + $this->authConfig = $config; + + return $this; + } + + /** + * Set HTTP timeout in seconds + */ + public function setHttpTimeout(int $timeout): self + { + $this->httpTimeout = $timeout; + + return $this; + } + + /** + * Convert endpoint to MCP tool parameters + */ + public function convertEndpointToTool(array $endpoint, string $className): array + { + $toolName = $this->generateToolName($endpoint); + $description = $this->generateDescription($endpoint); + $inputSchema = $this->generateInputSchema($endpoint); + $annotations = $this->generateAnnotations($endpoint); + $executeLogic = $this->generateExecuteLogic($endpoint); + $imports = $this->generateImports($endpoint); + + return [ + 'className' => $className, + 'toolName' => $toolName, + 'description' => $description, + 'inputSchema' => $inputSchema, + 'annotations' => $annotations, + 'executeLogic' => $executeLogic, + 'imports' => $imports, + ]; + } + + /** + * Generate tool name from endpoint + */ + protected function generateToolName(array $endpoint): string + { + // Check if operationId is a hash (32 char hex string) + $useOperationId = ! empty($endpoint['operationId']) + && ! preg_match('/^[a-f0-9]{32}$/i', $endpoint['operationId']); + + if ($useOperationId) { + return Str::kebab($endpoint['operationId']); + } + + // Generate from method and path + $method = strtolower($endpoint['method']); + $path = $this->convertPathToKebab($endpoint['path']); + + return "{$method}-{$path}"; + } + + /** + * Convert API path to kebab-case name + * Example: /lol/{region}/server-stats -> lol-region-server-stats + */ + protected function convertPathToKebab(string $path): string + { + // Remove leading/trailing slashes + $path = trim($path, '/'); + + // Replace path parameters {param} with just param + $path = preg_replace('/\{([^}]+)\}/', '$1', $path); + + // Replace forward slashes with hyphens + $path = str_replace('/', '-', $path); + + // Convert to kebab case if needed (handles camelCase and PascalCase) + $path = Str::kebab($path); + + // Remove any double hyphens + $path = preg_replace('/-+/', '-', $path); + + return $path; + } + + /** + * Generate description + */ + protected function generateDescription(array $endpoint): string + { + $description = $endpoint['summary'] ?: $endpoint['description']; + + if (! $description) { + $description = "{$endpoint['method']} {$endpoint['path']}"; + } + + // Add endpoint info + $description .= " [API: {$endpoint['method']} {$endpoint['path']}]"; + + return addslashes($description); + } + + /** + * Generate input schema + */ + protected function generateInputSchema(array $endpoint): array + { + $properties = []; + $required = []; + + // Process parameters + foreach ($endpoint['parameters'] as $param) { + $propName = $param['name']; + + $properties[$propName] = [ + 'type' => $this->mapSwaggerTypeToJsonSchema($param['type']), + 'description' => $param['description']." (in: {$param['in']})", + ]; + + if ($param['required']) { + $required[] = $propName; + } + + // Add schema constraints if available + if (isset($param['schema'])) { + $this->addSchemaConstraints($properties[$propName], $param['schema']); + } + } + + // Process request body + if ($endpoint['requestBody']) { + // For simplicity, we'll create a 'body' parameter + $properties['body'] = [ + 'type' => 'object', + 'description' => $endpoint['requestBody']['description'] ?? 'Request body', + ]; + + if ($endpoint['requestBody']['required']) { + $required[] = 'body'; + } + + // Try to extract schema from content + if (! empty($endpoint['requestBody']['content']['application/json']['schema'])) { + $schema = $endpoint['requestBody']['content']['application/json']['schema']; + if (isset($schema['properties'])) { + $properties['body']['properties'] = $this->convertSchemaProperties($schema['properties']); + if (isset($schema['required'])) { + $properties['body']['required'] = $schema['required']; + } + } + } + } + + return [ + 'type' => 'object', + 'properties' => $properties, + 'required' => $required, + ]; + } + + /** + * Convert schema properties + */ + protected function convertSchemaProperties(array $properties): array + { + $converted = []; + + foreach ($properties as $name => $prop) { + $converted[$name] = [ + 'type' => $prop['type'] ?? 'string', + 'description' => $prop['description'] ?? '', + ]; + + if (isset($prop['enum'])) { + $converted[$name]['enum'] = $prop['enum']; + } + + if (isset($prop['default'])) { + $converted[$name]['default'] = $prop['default']; + } + } + + return $converted; + } + + /** + * Map Swagger type to JSON Schema type + */ + protected function mapSwaggerTypeToJsonSchema(string $type): string + { + $mapping = [ + 'integer' => 'integer', + 'number' => 'number', + 'string' => 'string', + 'boolean' => 'boolean', + 'array' => 'array', + 'object' => 'object', + 'file' => 'string', // File uploads as string (path or base64) + ]; + + return $mapping[$type] ?? 'string'; + } + + /** + * Add schema constraints + */ + protected function addSchemaConstraints(array &$property, array $schema): void + { + if (isset($schema['enum'])) { + $property['enum'] = $schema['enum']; + } + + if (isset($schema['minimum'])) { + $property['minimum'] = $schema['minimum']; + } + + if (isset($schema['maximum'])) { + $property['maximum'] = $schema['maximum']; + } + + if (isset($schema['minLength'])) { + $property['minLength'] = $schema['minLength']; + } + + if (isset($schema['maxLength'])) { + $property['maxLength'] = $schema['maxLength']; + } + + if (isset($schema['pattern'])) { + $property['pattern'] = $schema['pattern']; + } + + if (isset($schema['default'])) { + $property['default'] = $schema['default']; + } + } + + /** + * Generate annotations + */ + protected function generateAnnotations(array $endpoint): array + { + $method = strtoupper($endpoint['method']); + $isReadOnly = in_array($method, ['GET', 'HEAD', 'OPTIONS']); + + return [ + 'title' => $endpoint['summary'] ?: "{$method} {$endpoint['path']}", + 'readOnlyHint' => $isReadOnly, + 'destructiveHint' => $method === 'DELETE', + 'idempotentHint' => in_array($method, ['GET', 'PUT', 'DELETE', 'HEAD', 'OPTIONS']), + 'openWorldHint' => true, // External API call + 'deprecated' => $endpoint['deprecated'] ?? false, + ]; + } + + /** + * Generate execute logic + */ + protected function generateExecuteLogic(array $endpoint): string + { + $method = strtolower($endpoint['method']); + $path = $endpoint['path']; + + $logic = <<<'PHP' + // Validate input parameters + $validator = Validator::make($arguments, [ + // Add validation rules based on schema + ]); + + if ($validator->fails()) { + throw new JsonRpcErrorException( + 'Validation failed: ' . $validator->errors()->first(), + JsonRpcErrorCode::INVALID_PARAMS + ); + } + +PHP; + + // Build URL with path parameters + $logic .= $this->generateUrlBuilder($path, $endpoint['parameters']); + + // Add authentication + $logic .= $this->generateAuthLogic($endpoint); + + // Build request + $logic .= $this->generateHttpRequest($method, $endpoint); + + // Handle response + $logic .= <<<'PHP' + + // Check response status + if (!$response->successful()) { + throw new JsonRpcErrorException( + 'API request failed: ' . $response->body(), + JsonRpcErrorCode::INTERNAL_ERROR + ); + } + + // Return response data + return [ + 'success' => true, + 'data' => $response->json(), + 'status' => $response->status(), + ]; +PHP; + + return $logic; + } + + /** + * Generate URL builder code + */ + protected function generateUrlBuilder(string $path, array $parameters): string + { + $baseUrl = $this->parser->getBaseUrl() ?: 'https://api.example.com'; + + $code = " // Build URL\n"; + $code .= " \$url = '{$baseUrl}{$path}';\n"; + + // Replace path parameters + $pathParams = array_filter($parameters, fn ($p) => $p['in'] === 'path'); + foreach ($pathParams as $param) { + $name = $param['name']; + $code .= " \$url = str_replace('{{$name}}', \$arguments['{$name}'] ?? '', \$url);\n"; + } + + $code .= "\n"; + + return $code; + } + + /** + * Generate authentication logic + */ + protected function generateAuthLogic(array $endpoint): string + { + if (empty($endpoint['security']) && empty($this->authConfig)) { + return ''; + } + + $code = " // Authentication\n"; + $code .= " \$headers = [];\n"; + + // Simple bearer token example + if (! empty($this->authConfig['bearer_token'])) { + $code .= " \$headers['Authorization'] = 'Bearer ' . config('services.api.token');\n"; + } + + // API Key example + if (! empty($this->authConfig['api_key'])) { + $location = $this->authConfig['api_key']['location'] ?? 'header'; + $name = $this->authConfig['api_key']['name'] ?? 'X-API-Key'; + + if ($location === 'header') { + $code .= " \$headers['{$name}'] = config('services.api.key');\n"; + } + } + + $code .= "\n"; + + return $code; + } + + /** + * Generate HTTP request code + */ + protected function generateHttpRequest(string $method, array $endpoint): string + { + $code = $this->generateHttpClientSetup(); + $code .= $this->generateQueryParametersCode($endpoint['parameters']); + $code .= $this->generateHttpMethodCall($method); + + return $code; + } + + /** + * Generate HTTP client setup code + */ + protected function generateHttpClientSetup(): string + { + return " // Build request\n". + " \$request = Http::withHeaders(\$headers ?? [])\n". + " ->timeout({$this->httpTimeout})\n". + " ->retry(3, 100);\n\n"; + } + + /** + * Generate query parameters handling code + */ + protected function generateQueryParametersCode(array $parameters): string + { + $queryParams = array_filter($parameters, fn ($p) => $p['in'] === 'query'); + + if (empty($queryParams)) { + return ''; + } + + $code = " // Add query parameters\n"; + $code .= " \$queryParams = [];\n"; + + foreach ($queryParams as $param) { + $name = $param['name']; + $code .= " if (isset(\$arguments['{$name}'])) {\n"; + $code .= " \$queryParams['{$name}'] = \$arguments['{$name}'];\n"; + $code .= " }\n"; + } + + $code .= " if (!empty(\$queryParams)) {\n"; + $code .= " \$request = \$request->withQueryParameters(\$queryParams);\n"; + $code .= " }\n\n"; + + return $code; + } + + /** + * Generate HTTP method call code + */ + protected function generateHttpMethodCall(string $method): string + { + $code = " // Execute request\n"; + + return $code.match ($method) { + 'get' => " \$response = \$request->get(\$url);\n", + 'post' => " \$response = \$request->post(\$url, \$arguments['body'] ?? []);\n", + 'put' => " \$response = \$request->put(\$url, \$arguments['body'] ?? []);\n", + 'patch' => " \$response = \$request->patch(\$url, \$arguments['body'] ?? []);\n", + 'delete' => " \$response = \$request->delete(\$url);\n", + default => " \$response = \$request->{$method}(\$url);\n", + }; + } + + /** + * Generate imports + */ + protected function generateImports(array $endpoint): array + { + // $endpoint parameter is for future extensibility (e.g., different imports based on endpoint type) + return [ + 'Illuminate\\Support\\Facades\\Http', + ]; + } + + /** + * Generate class name from endpoint + */ + public function generateClassName(array $endpoint, ?string $prefix = null): string + { + // Check if operationId is a hash (32 char hex string) + $useOperationId = ! empty($endpoint['operationId']) + && ! preg_match('/^[a-f0-9]{32}$/i', $endpoint['operationId']); + + if ($useOperationId) { + $name = Str::studly($endpoint['operationId']); + } else { + // Generate from method and path + $method = ucfirst(strtolower($endpoint['method'])); + $pathName = $this->convertPathToStudly($endpoint['path']); + $name = "{$method}{$pathName}"; + } + + if ($prefix) { + $name = "{$prefix}{$name}"; + } + + // Ensure it ends with Tool + if (! Str::endsWith($name, 'Tool')) { + $name .= 'Tool'; + } + + return $name; + } + + /** + * Convert API path to StudlyCase name + * Example: /lol/{region}/server-stats -> LolRegionServerStats + */ + protected function convertPathToStudly(string $path): string + { + // Remove leading/trailing slashes + $path = trim($path, '/'); + + // Split by forward slashes + $segments = explode('/', $path); + + // Process each segment + $processed = []; + foreach ($segments as $segment) { + // Remove curly braces from parameters + $segment = str_replace(['{', '}'], '', $segment); + + // Convert each segment to StudlyCase + // This handles kebab-case (server-stats), snake_case, and camelCase + $processed[] = Str::studly($segment); + } + + // Join all segments + return implode('', $processed); + } + + /** + * Generate resource class name from endpoint + */ + public function generateResourceClassName(array $endpoint, ?string $prefix = null): string + { + // Check if operationId is a hash (32 char hex string) + $useOperationId = ! empty($endpoint['operationId']) + && ! preg_match('/^[a-f0-9]{32}$/i', $endpoint['operationId']); + + if ($useOperationId) { + $name = Str::studly($endpoint['operationId']); + } else { + // Generate from path only (no method prefix for resources) + $pathName = $this->convertPathToStudly($endpoint['path']); + $name = $pathName; + } + + if ($prefix) { + $name = "{$prefix}{$name}"; + } + + // Ensure it ends with Resource + if (! Str::endsWith($name, 'Resource')) { + $name .= 'Resource'; + } + + return $name; + } + + /** + * Convert endpoint to MCP resource parameters + */ + public function convertEndpointToResource(array $endpoint, string $className): array + { + $uri = $this->generateResourceUri($endpoint); + $name = $this->generateResourceName($endpoint); + $description = $this->generateResourceDescription($endpoint); + $readLogic = $this->generateResourceReadLogic($endpoint); + + return [ + 'className' => $className, + 'uri' => $uri, + 'name' => $name, + 'description' => $description, + 'mimeType' => 'application/json', + 'readLogic' => $readLogic, + ]; + } + + /** + * Generate resource URI from endpoint + */ + protected function generateResourceUri(array $endpoint): string + { + // Convert API path to a resource URI + // Example: /api/users/{id} -> api://users/{id} + $path = trim($endpoint['path'], '/'); + + // Remove common API prefixes + $path = preg_replace('/^api\//', '', $path); + + return "api://{$path}"; + } + + /** + * Generate resource name from endpoint + */ + protected function generateResourceName(array $endpoint): string + { + if (! empty($endpoint['summary'])) { + return $endpoint['summary']; + } + + // Generate from path + $path = trim($endpoint['path'], '/'); + $path = str_replace(['{', '}'], '', $path); + $parts = explode('/', $path); + + return ucfirst(end($parts)).' Data'; + } + + /** + * Generate resource description from endpoint + */ + protected function generateResourceDescription(array $endpoint): string + { + $description = $endpoint['description'] ?: $endpoint['summary']; + + if (! $description) { + $description = "Resource for {$endpoint['path']} endpoint"; + } + + $description .= " [API: GET {$endpoint['path']}]"; + + // Add parameter info + if (! empty($endpoint['parameters'])) { + $params = array_map(fn ($p) => $p['name'], $endpoint['parameters']); + $description .= ' Parameters: '.implode(', ', $params); + } + + return addslashes($description); + } + + /** + * Generate resource read logic + */ + protected function generateResourceReadLogic(array $endpoint): string + { + $baseUrl = $this->parser->getBaseUrl() ?: 'https://api.example.com'; + $path = $endpoint['path']; + + $logic = <<<'PHP' + try { + // Build URL + $url = '{{ baseUrl }}{{ path }}'; + + // Replace path parameters if provided + // Note: In a real resource, you'd get these from the URI or context +{{ pathParams }} + + // Prepare headers + $headers = []; +{{ authHeaders }} + + // Make HTTP request + $response = Http::withHeaders($headers) + ->timeout({{ timeout }}) + ->retry(3, 100) +{{ queryParams }} + ->get($url); + + if (!$response->successful()) { + throw new \Exception("API request failed: Status {$response->status()}"); + } + + return [ + 'uri' => $this->uri, + 'mimeType' => 'application/json', + 'text' => $response->body(), + ]; + } catch (\Exception $e) { + throw new \RuntimeException( + "Failed to read resource {$this->uri}: " . $e->getMessage() + ); + } +PHP; + + // Replace placeholders + $logic = str_replace('{{ baseUrl }}', $baseUrl, $logic); + $logic = str_replace('{{ path }}', $path, $logic); + $logic = str_replace('{{ timeout }}', (string) $this->httpTimeout, $logic); + + // Add path parameter replacements + $pathParams = $this->generateResourcePathParams($endpoint['parameters'] ?? []); + $logic = str_replace('{{ pathParams }}', $pathParams, $logic); + + // Add authentication headers + $authHeaders = $this->generateResourceAuthHeaders($endpoint); + $logic = str_replace('{{ authHeaders }}', $authHeaders, $logic); + + // Add query parameters + $queryParams = $this->generateResourceQueryParams($endpoint['parameters'] ?? []); + $logic = str_replace('{{ queryParams }}', $queryParams, $logic); + + return $logic; + } + + /** + * Generate path parameter replacements for resource + */ + protected function generateResourcePathParams(array $parameters): string + { + $pathParams = array_filter($parameters, fn ($p) => $p['in'] === 'path'); + + if (empty($pathParams)) { + return ''; + } + + $code = ''; + foreach ($pathParams as $param) { + $name = $param['name']; + $code .= " // TODO: Implement logic to get '{$name}' value\n"; + $code .= " // \$url = str_replace('{{$name}}', \$valueFor".Str::studly($name).", \$url);\n"; + } + + return rtrim($code); + } + + /** + * Generate authentication headers for resource + */ + protected function generateResourceAuthHeaders(array $endpoint): string + { + if (empty($endpoint['security']) && empty($this->authConfig)) { + return ''; + } + + $code = ''; + + if (! empty($this->authConfig['bearer_token'])) { + $code .= " \$headers['Authorization'] = 'Bearer ' . config('services.api.token');\n"; + } + + if (! empty($this->authConfig['api_key'])) { + $name = $this->authConfig['api_key']['name'] ?? 'X-API-Key'; + $code .= " \$headers['{$name}'] = config('services.api.key');\n"; + } + + return rtrim($code); + } + + /** + * Generate query parameters for resource + */ + protected function generateResourceQueryParams(array $parameters): string + { + $queryParams = array_filter($parameters, fn ($p) => $p['in'] === 'query'); + + if (empty($queryParams)) { + return ''; + } + + $code = " ->withQueryParameters([\n"; + foreach ($queryParams as $param) { + $name = $param['name']; + $required = $param['required'] ? 'required' : 'optional'; + $code .= " // '{$name}' => \$valueFor".Str::studly($name).", // {$required}\n"; + } + $code .= " ])\n"; + + return $code; + } +} diff --git a/src/stubs/resource.programmatic.stub b/src/stubs/resource.programmatic.stub new file mode 100644 index 0000000..75d2613 --- /dev/null +++ b/src/stubs/resource.programmatic.stub @@ -0,0 +1,46 @@ +converter = new SwaggerToMcpConverter($parser); +}); + +test('converts paths to class names correctly', function ($path, $method, $operationId, $expected) { + $endpoint = [ + 'path' => $path, + 'method' => $method, + 'operationId' => $operationId, + ]; + + $className = $this->converter->generateClassName($endpoint); + expect($className)->toBe($expected); +})->with([ + // OP.GG style paths (no operationId) + ['/lol/{region}/server-stats', 'GET', null, 'GetLolRegionServerStatsTool'], + ['/lol/{region}/champions/{championId}', 'GET', null, 'GetLolRegionChampionsChampionIdTool'], + ['/valorant/{region}/players/{playerId}/matches', 'GET', null, 'GetValorantRegionPlayersPlayerIdMatchesTool'], + + // With hash operationId (should use path-based naming) + ['/lol/{region}/server-stats', 'GET', '5784a7dfd226e1621b0e6ee8c4f39407', 'GetLolRegionServerStatsTool'], + ['/api/users', 'POST', 'df2eafc7cbf65a9ad14aceecdef3dbd3', 'PostApiUsersTool'], + + // With proper operationId (should use operationId) + ['/users', 'GET', 'getUsers', 'GetUsersTool'], + ['/login', 'POST', 'userLogin', 'UserLoginTool'], + + // Kebab-case paths (no operationId) + ['/user-profiles/{id}/match-history', 'POST', null, 'PostUserProfilesIdMatchHistoryTool'], + ['/api/v2/game-stats', 'GET', null, 'GetApiV2GameStatsTool'], + + // Snake_case paths + ['/user_profiles/{user_id}/game_stats', 'PUT', null, 'PutUserProfilesUserIdGameStatsTool'], + + // CamelCase paths + ['/userProfiles/{userId}/gameStats', 'DELETE', null, 'DeleteUserProfilesUserIdGameStatsTool'], + + // Mixed cases + ['/api/v1/user-profiles/{user_id}/gameStats', 'PATCH', null, 'PatchApiV1UserProfilesUserIdGameStatsTool'], + + // Simple paths + ['/users', 'GET', null, 'GetUsersTool'], + ['/login', 'POST', null, 'PostLoginTool'], + + // Nested resources + ['/teams/{teamId}/players/{playerId}/stats', 'GET', null, 'GetTeamsTeamIdPlayersPlayerIdStatsTool'], + + // Paths with numbers + ['/api/v3/stats', 'GET', null, 'GetApiV3StatsTool'], + ['/2024/tournaments', 'GET', null, 'Get2024TournamentsTool'], +]); + +test('converts paths to tool names correctly', function ($path, $method, $expected) { + $endpoint = [ + 'path' => $path, + 'method' => $method, + 'operationId' => null, + 'summary' => '', + 'description' => '', + 'tags' => [], + 'deprecated' => false, + 'parameters' => [], + 'requestBody' => null, + 'responses' => [], + 'security' => [], + ]; + + $toolParams = $this->converter->convertEndpointToTool($endpoint, 'TestTool'); + expect($toolParams['toolName'])->toBe($expected); +})->with([ + // OP.GG style paths + ['/lol/{region}/server-stats', 'GET', 'get-lol-region-server-stats'], + ['/lol/{region}/champions/{championId}', 'GET', 'get-lol-region-champions-champion-id'], + + // Kebab-case paths + ['/user-profiles/{id}/match-history', 'POST', 'post-user-profiles-id-match-history'], + + // Simple paths + ['/users', 'GET', 'get-users'], + ['/login', 'POST', 'post-login'], +]); + +test('detects and ignores hash operationIds', function () { + // Test with 32-char hex hash (MD5-like) + $endpoint = [ + 'path' => '/api/users/{id}', + 'method' => 'GET', + 'operationId' => '5784a7dfd226e1621b0e6ee8c4f39407', + ]; + + $className = $this->converter->generateClassName($endpoint); + expect($className)->toBe('GetApiUsersIdTool'); + + // Test with proper operationId + $endpoint2 = [ + 'path' => '/api/users/{id}', + 'method' => 'GET', + 'operationId' => 'getUserById', + ]; + + $className2 = $this->converter->generateClassName($endpoint2); + expect($className2)->toBe('GetUserByIdTool'); + + // Test with uppercase hash + $endpoint3 = [ + 'path' => '/api/orders', + 'method' => 'POST', + 'operationId' => 'DF2EAFC7CBF65A9AD14ACEECDEF3DBD3', + ]; + + $className3 = $this->converter->generateClassName($endpoint3); + expect($className3)->toBe('PostApiOrdersTool'); +}); diff --git a/tests/Unit/ResourceGenerationTest.php b/tests/Unit/ResourceGenerationTest.php new file mode 100644 index 0000000..9901dca --- /dev/null +++ b/tests/Unit/ResourceGenerationTest.php @@ -0,0 +1,118 @@ +converter = new SwaggerToMcpConverter($parser); +}); + +test('generates resource class names correctly', function ($path, $operationId, $expected) { + $endpoint = [ + 'path' => $path, + 'method' => 'GET', + 'operationId' => $operationId, + ]; + + $className = $this->converter->generateResourceClassName($endpoint); + expect($className)->toBe($expected); +})->with([ + // Path-based naming (no operationId) + ['/lol/{region}/server-stats', null, 'LolRegionServerStatsResource'], + ['/api/users', null, 'ApiUsersResource'], + ['/users/{id}', null, 'UsersIdResource'], + + // With proper operationId + ['/users', 'getUsers', 'GetUsersResource'], + ['/posts/{id}', 'getPostById', 'GetPostByIdResource'], + + // With hash operationId (should use path-based naming) + ['/api/data', '5784a7dfd226e1621b0e6ee8c4f39407', 'ApiDataResource'], +]); + +test('converts endpoint to resource with correct URI', function () { + $parser = new SwaggerParser; + $converter = new SwaggerToMcpConverter($parser); + + $endpoint = [ + 'path' => '/api/users/{id}', + 'method' => 'GET', + 'operationId' => 'getUserById', + 'summary' => 'Get user by ID', + 'description' => 'Returns a single user', + 'parameters' => [ + ['name' => 'id', 'in' => 'path', 'required' => true, 'type' => 'integer'], + ], + 'deprecated' => false, + 'tags' => ['users'], + 'requestBody' => null, + 'responses' => [], + 'security' => [], + ]; + + $resourceParams = $converter->convertEndpointToResource($endpoint, 'GetUserByIdResource'); + + expect($resourceParams)->toHaveKeys(['className', 'uri', 'name', 'description', 'mimeType', 'readLogic']); + expect($resourceParams['className'])->toBe('GetUserByIdResource'); + expect($resourceParams['uri'])->toBe('api://users/{id}'); + expect($resourceParams['name'])->toBe('Get user by ID'); + expect($resourceParams['mimeType'])->toBe('application/json'); +}); + +test('generates resource URI correctly', function ($path, $expectedUri) { + $parser = new SwaggerParser; + $converter = new SwaggerToMcpConverter($parser); + + $endpoint = [ + 'path' => $path, + 'method' => 'GET', + 'operationId' => null, + 'summary' => '', + 'description' => '', + 'parameters' => [], + 'deprecated' => false, + 'tags' => [], + 'requestBody' => null, + 'responses' => [], + 'security' => [], + ]; + $resourceParams = $converter->convertEndpointToResource($endpoint, 'TestResource'); + + expect($resourceParams['uri'])->toBe($expectedUri); +})->with([ + ['/api/users', 'api://users'], + ['/users/{id}', 'api://users/{id}'], + ['/api/posts/{postId}/comments', 'api://posts/{postId}/comments'], + ['/data', 'api://data'], +]); + +test('includes authentication in resource read logic', function () { + $parser = new SwaggerParser; + $converter = new SwaggerToMcpConverter($parser); + + // Set auth config + $converter->setAuthConfig([ + 'bearer_token' => true, + 'api_key' => ['location' => 'header', 'name' => 'X-API-Key'], + ]); + + $endpoint = [ + 'path' => '/api/protected', + 'method' => 'GET', + 'operationId' => null, + 'summary' => 'Protected endpoint', + 'description' => '', + 'parameters' => [], + 'deprecated' => false, + 'tags' => [], + 'requestBody' => null, + 'responses' => [], + 'security' => [['bearerAuth' => []]], + ]; + + $resourceParams = $converter->convertEndpointToResource($endpoint, 'ProtectedResource'); + + expect($resourceParams['readLogic'])->toContain("\$headers['Authorization'] = 'Bearer '"); + expect($resourceParams['readLogic'])->toContain("\$headers['X-API-Key'] = config('services.api.key')"); +}); diff --git a/tests/Unit/SwaggerParserTest.php b/tests/Unit/SwaggerParserTest.php new file mode 100644 index 0000000..a76a304 --- /dev/null +++ b/tests/Unit/SwaggerParserTest.php @@ -0,0 +1,187 @@ +parser = new SwaggerParser; + $this->specPath = __DIR__.'/../fixtures/petstore.json'; + } + + public function test_can_load_spec_from_file() + { + $this->parser->load($this->specPath); + + $info = $this->parser->getInfo(); + + $this->assertEquals('openapi-3.0.0', $info['version']); + $this->assertEquals('Petstore API', $info['title']); + $this->assertEquals('https://petstore.example.com/api/v1', $info['baseUrl']); + $this->assertEquals(4, $info['totalEndpoints']); + $this->assertContains('pets', $info['tags']); + } + + public function test_can_extract_endpoints() + { + $this->parser->load($this->specPath); + + $endpoints = $this->parser->getEndpoints(); + + $this->assertCount(4, $endpoints); + + // Check first endpoint + $firstEndpoint = $endpoints[0]; + $this->assertEquals('/pets', $firstEndpoint['path']); + $this->assertEquals('GET', $firstEndpoint['method']); + $this->assertEquals('listPets', $firstEndpoint['operationId']); + $this->assertFalse($firstEndpoint['deprecated']); + + // Check parameters + $this->assertCount(2, $firstEndpoint['parameters']); + $this->assertEquals('limit', $firstEndpoint['parameters'][0]['name']); + $this->assertEquals('query', $firstEndpoint['parameters'][0]['in']); + } + + public function test_can_group_endpoints_by_tag() + { + $this->parser->load($this->specPath); + + $byTag = $this->parser->getEndpointsByTag(); + + $this->assertArrayHasKey('pets', $byTag); + $this->assertCount(4, $byTag['pets']); + } + + public function test_can_extract_security_schemes() + { + $this->parser->load($this->specPath); + + $schemes = $this->parser->getSecuritySchemes(); + + $this->assertArrayHasKey('bearerAuth', $schemes); + $this->assertArrayHasKey('apiKey', $schemes); + + $this->assertEquals('http', $schemes['bearerAuth']['type']); + $this->assertEquals('bearer', $schemes['bearerAuth']['scheme']); + + $this->assertEquals('apiKey', $schemes['apiKey']['type']); + $this->assertEquals('header', $schemes['apiKey']['in']); + $this->assertEquals('X-API-Key', $schemes['apiKey']['name']); + } + + public function test_can_convert_endpoint_to_tool() + { + $this->parser->load($this->specPath); + + $converter = new SwaggerToMcpConverter($this->parser); + + $endpoints = $this->parser->getEndpoints(); + $endpoint = $endpoints[0]; // listPets + + $toolParams = $converter->convertEndpointToTool($endpoint, 'ListPetsTool'); + + $this->assertEquals('ListPetsTool', $toolParams['className']); + $this->assertEquals('list-pets', $toolParams['toolName']); + $this->assertStringContainsString('List all pets', $toolParams['description']); + + // Check input schema + $this->assertEquals('object', $toolParams['inputSchema']['type']); + $this->assertArrayHasKey('limit', $toolParams['inputSchema']['properties']); + $this->assertArrayHasKey('status', $toolParams['inputSchema']['properties']); + + // Check annotations + $this->assertTrue($toolParams['annotations']['readOnlyHint']); + $this->assertFalse($toolParams['annotations']['destructiveHint']); + + // Check imports + $this->assertContains('Illuminate\\Support\\Facades\\Http', $toolParams['imports']); + } + + public function test_handles_deprecated_endpoints() + { + $this->parser->load($this->specPath); + + $endpoints = $this->parser->getEndpoints(); + + // Find the deprecated delete endpoint + $deleteEndpoint = null; + foreach ($endpoints as $endpoint) { + if ($endpoint['operationId'] === 'deletePet') { + $deleteEndpoint = $endpoint; + break; + } + } + + $this->assertNotNull($deleteEndpoint); + $this->assertTrue($deleteEndpoint['deprecated']); + } + + public function test_can_generate_class_names() + { + $this->parser->load($this->specPath); + + $converter = new SwaggerToMcpConverter($this->parser); + + $endpoints = $this->parser->getEndpoints(); + + foreach ($endpoints as $endpoint) { + $className = $converter->generateClassName($endpoint); + + // Check that class names end with Tool + $this->assertStringEndsWith('Tool', $className); + + // Check specific names + if ($endpoint['operationId'] === 'listPets') { + $this->assertEquals('ListPetsTool', $className); + } elseif ($endpoint['operationId'] === 'createPet') { + $this->assertEquals('CreatePetTool', $className); + } elseif ($endpoint['operationId'] === 'getPetById') { + $this->assertEquals('GetPetByIdTool', $className); + } + } + + // Test path-based naming (without operationId) + $testEndpoint = [ + 'path' => '/lol/{region}/server-stats', + 'method' => 'GET', + 'operationId' => null, + ]; + + $className = $converter->generateClassName($testEndpoint); + $this->assertEquals('GetLolRegionServerStatsTool', $className); + + // Test with kebab-case and underscores + $testEndpoint2 = [ + 'path' => '/api/v2/user-profiles/{user_id}/match-history', + 'method' => 'POST', + 'operationId' => null, + ]; + + $className2 = $converter->generateClassName($testEndpoint2); + $this->assertEquals('PostApiV2UserProfilesUserIdMatchHistoryTool', $className2); + } + + public function test_can_set_custom_base_url() + { + $this->parser->load($this->specPath); + + $originalUrl = $this->parser->getBaseUrl(); + $this->assertEquals('https://petstore.example.com/api/v1', $originalUrl); + + $this->parser->setBaseUrl('https://custom.api.com'); + + $newUrl = $this->parser->getBaseUrl(); + $this->assertEquals('https://custom.api.com', $newUrl); + } +} diff --git a/tests/fixtures/petstore.json b/tests/fixtures/petstore.json new file mode 100644 index 0000000..ed857f3 --- /dev/null +++ b/tests/fixtures/petstore.json @@ -0,0 +1,222 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Petstore API", + "version": "1.0.0", + "description": "A simple Pet Store API for testing" + }, + "servers": [ + { + "url": "https://petstore.example.com/api/v1" + } + ], + "tags": [ + { + "name": "pets", + "description": "Pet operations" + }, + { + "name": "store", + "description": "Store operations" + } + ], + "paths": { + "/pets": { + "get": { + "operationId": "listPets", + "summary": "List all pets", + "description": "Returns a list of all available pets", + "tags": ["pets"], + "parameters": [ + { + "name": "limit", + "in": "query", + "description": "Maximum number of pets to return", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 10 + } + }, + { + "name": "status", + "in": "query", + "description": "Filter by pet status", + "required": false, + "schema": { + "type": "string", + "enum": ["available", "pending", "sold"] + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Pet" + } + } + } + } + } + } + }, + "post": { + "operationId": "createPet", + "summary": "Create a new pet", + "tags": ["pets"], + "requestBody": { + "required": true, + "description": "Pet object to create", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NewPet" + } + } + } + }, + "responses": { + "201": { + "description": "Pet created successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/pets/{petId}": { + "get": { + "operationId": "getPetById", + "summary": "Get a pet by ID", + "tags": ["pets"], + "parameters": [ + { + "name": "petId", + "in": "path", + "description": "ID of the pet to retrieve", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + } + }, + "404": { + "description": "Pet not found" + } + } + }, + "delete": { + "operationId": "deletePet", + "summary": "Delete a pet", + "tags": ["pets"], + "deprecated": true, + "parameters": [ + { + "name": "petId", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "204": { + "description": "Pet deleted successfully" + } + }, + "security": [ + { + "apiKey": [] + } + ] + } + } + }, + "components": { + "schemas": { + "Pet": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "Unique pet ID" + }, + "name": { + "type": "string", + "description": "Pet name" + }, + "category": { + "type": "string", + "description": "Pet category" + }, + "status": { + "type": "string", + "enum": ["available", "pending", "sold"], + "description": "Pet status" + } + }, + "required": ["id", "name"] + }, + "NewPet": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Pet name" + }, + "category": { + "type": "string", + "description": "Pet category" + }, + "status": { + "type": "string", + "enum": ["available", "pending", "sold"], + "default": "available" + } + }, + "required": ["name"] + } + }, + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT" + }, + "apiKey": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key" + } + } + } +} \ No newline at end of file