From 4868901226a79bbd0e6f3811276092fe8bc9274b Mon Sep 17 00:00:00 2001 From: Sangrak Choi Date: Fri, 8 Aug 2025 16:09:52 +0900 Subject: [PATCH 1/7] feat(swagger): add automatic MCP tool generation from Swagger/OpenAPI specs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add command to generate MCP tools from Swagger 2.0 and OpenAPI 3.0 specifications - Support both local files and remote URLs as input sources - Parse endpoints and generate tool classes with HTTP client implementation - Handle authentication (Bearer tokens, API keys) - Auto-register generated tools in configuration - Include comprehensive test fixtures for Swagger/OpenAPI formats - Document new command with usage examples in README This enables rapid integration of external APIs by automatically converting their Swagger/OpenAPI documentation into functional MCP tools. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- README.md | 41 ++ .../Commands/GenerateFromSwaggerCommand.php | 250 ++++++++ src/LaravelMcpServerServiceProvider.php | 2 + src/Services/SwaggerParser.php | 269 +++++++++ src/Services/ToolGenerator.php | 543 ++++++++++++++++++ tests/fixtures/openapi-sample.json | 211 +++++++ tests/fixtures/swagger-sample.json | 213 +++++++ 7 files changed, 1529 insertions(+) create mode 100644 src/Console/Commands/GenerateFromSwaggerCommand.php create mode 100644 src/Services/SwaggerParser.php create mode 100644 src/Services/ToolGenerator.php create mode 100644 tests/fixtures/openapi-sample.json create mode 100644 tests/fixtures/swagger-sample.json diff --git a/README.md b/README.md index 1540322..07d16d2 100644 --- a/README.md +++ b/README.md @@ -310,6 +310,47 @@ 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 + +You can automatically generate MCP tools from Swagger/OpenAPI specifications: + +```bash +# From local file +php artisan mcp:generate-from-swagger path/to/swagger.json + +# From URL +php artisan mcp:generate-from-swagger https://api.example.com/swagger.json +``` + +This command: + +- Accepts both local file paths and URLs +- Supports both Swagger 2.0 and OpenAPI 3.0 formats +- Generates one MCP tool per endpoint (path + method combination) +- Includes HTTP client implementation using Laravel's Http facade +- Handles authentication (Bearer tokens, API keys) +- Auto-registers tools in configuration + +Options: +- `--output-dir`: Custom output directory (default: `app/MCP/Tools/Swagger`) +- `--base-url-env`: Environment variable name for API base URL (default: `SWAGGER_API_BASE_URL`) +- `--no-register`: Skip auto-registration in config +- `--force`: Overwrite existing files without confirmation + +Examples: +```bash +# Generate from local file +php artisan mcp:generate-from-swagger api-spec.json \ + --output-dir=app/MCP/Tools/API \ + --base-url-env=API_BASE_URL \ + --force + +# Generate from URL (e.g., OP.GG API) +php artisan mcp:generate-from-swagger https://data.op.gg/lol/swagger.json \ + --output-dir=app/MCP/Tools/OPGG \ + --base-url-env=OPGG_API_BASE_URL +``` + You can also manually create and register tools in `config/mcp-server.php`: ```php diff --git a/src/Console/Commands/GenerateFromSwaggerCommand.php b/src/Console/Commands/GenerateFromSwaggerCommand.php new file mode 100644 index 0000000..229b9e6 --- /dev/null +++ b/src/Console/Commands/GenerateFromSwaggerCommand.php @@ -0,0 +1,250 @@ +parser = $parser; + $this->generator = $generator; + } + + public function handle(): int + { + $source = $this->argument('source'); + + // Check if source is a URL + if (filter_var($source, FILTER_VALIDATE_URL)) { + $this->info('Downloading Swagger/OpenAPI specification from URL...'); + $json = $this->downloadFromUrl($source); + + if ($json === null) { + return Command::FAILURE; + } + } else { + // Treat as file path + if (! File::exists($source)) { + $this->error("File not found: {$source}"); + + return Command::FAILURE; + } + + $json = File::get($source); + } + + // Parse JSON + $spec = json_decode($json, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + $this->error('Invalid JSON file: '.json_last_error_msg()); + + return Command::FAILURE; + } + + $this->info('Parsing Swagger/OpenAPI specification...'); + + try { + // Parse the specification + $endpoints = $this->parser->parse($spec); + + if (empty($endpoints)) { + $this->warn('No endpoints found in the specification.'); + + return Command::SUCCESS; + } + + $endpointCount = count($endpoints); + $this->info("Found {$endpointCount} endpoints to process."); + + // Setup output directory + $outputDir = base_path($this->option('output-dir')); + if (! File::exists($outputDir)) { + File::makeDirectory($outputDir, 0755, true); + } + + // Generate tools + $generatedTools = []; + $progressBar = $this->output->createProgressBar(count($endpoints)); + $progressBar->start(); + + foreach ($endpoints as $index => $endpoint) { + $this->newLine(); + $current = $index + 1; + $total = count($endpoints); + $this->info("Processing endpoint [{$current}/{$total}]: {$endpoint['method']} {$endpoint['path']}"); + + $toolClass = $this->generator->generateTool( + $endpoint, + $outputDir, + $this->option('base-url-env'), + $this->option('force') + ); + + if ($toolClass) { + $generatedTools[] = $toolClass; + } + + $progressBar->advance(); + } + + $progressBar->finish(); + $this->newLine(2); + + // Register tools if needed + if (! $this->option('no-register') && ! empty($generatedTools)) { + $this->info('Registering tools in configuration...'); + $registered = $this->registerTools($generatedTools); + + if ($registered > 0) { + $this->info("Successfully registered {$registered} tools."); + } + } + + // Display summary + $this->displaySummary(count($endpoints), count($generatedTools)); + + // Generate .env.example entry + $this->generateEnvExample($this->option('base-url-env')); + + return Command::SUCCESS; + + } catch (\Exception $e) { + $this->error('Error processing specification: '.$e->getMessage()); + + return Command::FAILURE; + } + } + + protected function registerTools(array $toolClasses): int + { + $configPath = config_path('mcp-server.php'); + + if (! File::exists($configPath)) { + $this->warn('Config file not found. Please publish the configuration first.'); + + return 0; + } + + $config = File::get($configPath); + $registered = 0; + + // Find the tools array + if (preg_match('/\'tools\'\s*=>\s*\[/', $config, $matches, PREG_OFFSET_CAPTURE)) { + $insertPosition = $matches[0][1] + strlen($matches[0][0]); + + // Check for existing tools and find the right position + $afterBracket = substr($config, $insertPosition); + + // Add comment for generated tools + $toolsToAdd = "\n // Generated from Swagger"; + + foreach ($toolClasses as $className) { + // Check if already registered + if (strpos($config, $className) === false) { + $toolsToAdd .= "\n \\{$className}::class,"; + $registered++; + } else { + $this->warn("Tool already registered: {$className}"); + } + } + + if ($registered > 0) { + // Insert the new tools + $newConfig = substr($config, 0, $insertPosition).$toolsToAdd.substr($config, $insertPosition); + File::put($configPath, $newConfig); + } + } + + return $registered; + } + + protected function displaySummary(int $totalEndpoints, int $generatedTools): void + { + $this->newLine(); + $this->info('=== Generation Summary ==='); + $this->line("Total endpoints processed: {$totalEndpoints}"); + $this->line("Tools generated: {$generatedTools}"); + + if ($generatedTools < $totalEndpoints) { + $skipped = $totalEndpoints - $generatedTools; + $this->warn("Tools skipped: {$skipped} (due to conflicts or errors)"); + } + } + + protected function generateEnvExample(string $envVar): void + { + $envExamplePath = base_path('.env.example'); + + if (! File::exists($envExamplePath)) { + return; + } + + $envContent = File::get($envExamplePath); + + if (strpos($envContent, $envVar) === false) { + $addition = "\n# Swagger API Configuration\n{$envVar}=https://api.example.com\n"; + File::append($envExamplePath, $addition); + $this->info("Added {$envVar} to .env.example"); + } + } + + protected function downloadFromUrl(string $url): ?string + { + try { + $response = Http::timeout(30) + ->withHeaders([ + 'Accept' => 'application/json', + 'User-Agent' => 'Laravel-MCP-Server/1.0', + ]) + ->get($url); + + if ($response->successful()) { + $this->info('Successfully downloaded specification from URL'); + + return $response->body(); + } + + $this->error("Failed to download from URL. Status: {$response->status()}"); + + if ($response->status() === 404) { + $this->error('The URL was not found. Please check the URL and try again.'); + } elseif ($response->status() === 403) { + $this->error('Access denied. The URL may require authentication.'); + } elseif ($response->status() >= 500) { + $this->error('Server error. Please try again later.'); + } + + return null; + } catch (\Exception $e) { + $this->error('Error downloading from URL: '.$e->getMessage()); + + if (str_contains($e->getMessage(), 'cURL error 6')) { + $this->error('Could not resolve host. Please check your internet connection and the URL.'); + } elseif (str_contains($e->getMessage(), 'cURL error 28')) { + $this->error('Request timed out. The server may be slow or unreachable.'); + } + + return null; + } + } +} diff --git a/src/LaravelMcpServerServiceProvider.php b/src/LaravelMcpServerServiceProvider.php index a8b399c..b57fccb 100644 --- a/src/LaravelMcpServerServiceProvider.php +++ b/src/LaravelMcpServerServiceProvider.php @@ -4,6 +4,7 @@ use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Route; +use OPGG\LaravelMcpServer\Console\Commands\GenerateFromSwaggerCommand; use OPGG\LaravelMcpServer\Console\Commands\MakeMcpNotificationCommand; use OPGG\LaravelMcpServer\Console\Commands\MakeMcpPromptCommand; use OPGG\LaravelMcpServer\Console\Commands\MakeMcpResourceCommand; @@ -33,6 +34,7 @@ public function configurePackage(Package $package): void ->name('laravel-mcp-server') ->hasConfigFile('mcp-server') ->hasCommands([ + GenerateFromSwaggerCommand::class, MakeMcpToolCommand::class, MakeMcpResourceCommand::class, MakeMcpResourceTemplateCommand::class, diff --git a/src/Services/SwaggerParser.php b/src/Services/SwaggerParser.php new file mode 100644 index 0000000..d6f765e --- /dev/null +++ b/src/Services/SwaggerParser.php @@ -0,0 +1,269 @@ +detectVersion($spec); + + if ($version === '2.0') { + return $this->parseSwagger2($spec); + } elseif (str_starts_with($version, '3.')) { + return $this->parseOpenApi3($spec); + } + + throw new \InvalidArgumentException("Unsupported specification version: {$version}"); + } + + /** + * Detect specification version + */ + protected function detectVersion(array $spec): string + { + if (isset($spec['swagger'])) { + return $spec['swagger']; + } + + if (isset($spec['openapi'])) { + return $spec['openapi']; + } + + throw new \InvalidArgumentException('Unable to detect Swagger/OpenAPI version'); + } + + /** + * Parse Swagger 2.0 specification + */ + protected function parseSwagger2(array $spec): array + { + $endpoints = []; + $basePath = $spec['basePath'] ?? ''; + $host = $spec['host'] ?? ''; + $schemes = $spec['schemes'] ?? ['https']; + $baseUrl = "{$schemes[0]}://{$host}{$basePath}"; + + $globalSecurity = $spec['security'] ?? []; + $securityDefinitions = $spec['securityDefinitions'] ?? []; + + foreach ($spec['paths'] ?? [] as $path => $pathItem) { + foreach ($pathItem as $method => $operation) { + if (in_array($method, ['get', 'post', 'put', 'patch', 'delete', 'head', 'options'])) { + $endpoints[] = $this->parseEndpoint( + $path, + $method, + $operation, + $baseUrl, + $globalSecurity, + $securityDefinitions, + '2.0' + ); + } + } + } + + return $endpoints; + } + + /** + * Parse OpenAPI 3.0 specification + */ + protected function parseOpenApi3(array $spec): array + { + $endpoints = []; + $servers = $spec['servers'] ?? [['url' => '']]; + $baseUrl = $servers[0]['url'] ?? ''; + + $globalSecurity = $spec['security'] ?? []; + $securitySchemes = $spec['components']['securitySchemes'] ?? []; + + foreach ($spec['paths'] ?? [] as $path => $pathItem) { + foreach ($pathItem as $method => $operation) { + if (in_array($method, ['get', 'post', 'put', 'patch', 'delete', 'head', 'options'])) { + $endpoints[] = $this->parseEndpoint( + $path, + $method, + $operation, + $baseUrl, + $globalSecurity, + $securitySchemes, + '3.0' + ); + } + } + } + + return $endpoints; + } + + /** + * Parse individual endpoint + */ + protected function parseEndpoint( + string $path, + string $method, + array $operation, + string $baseUrl, + array $globalSecurity, + array $securityDefinitions, + string $version + ): array { + $endpoint = [ + 'path' => $path, + 'method' => strtoupper($method), + 'operationId' => $operation['operationId'] ?? $this->generateOperationId($method, $path), + 'summary' => $operation['summary'] ?? '', + 'description' => $operation['description'] ?? '', + 'baseUrl' => $baseUrl, + 'parameters' => [], + 'requestBody' => null, + 'security' => $operation['security'] ?? $globalSecurity, + 'securityDefinitions' => $securityDefinitions, + ]; + + // Parse parameters + if ($version === '2.0') { + $endpoint['parameters'] = $this->parseSwagger2Parameters($operation['parameters'] ?? []); + + // Handle body parameters separately in Swagger 2.0 + foreach ($operation['parameters'] ?? [] as $param) { + if (($param['in'] ?? '') === 'body') { + $endpoint['requestBody'] = $this->convertSwagger2BodyToSchema($param); + } + } + } else { + $endpoint['parameters'] = $this->parseOpenApi3Parameters($operation['parameters'] ?? []); + + // Handle requestBody in OpenAPI 3.0 + if (isset($operation['requestBody'])) { + $endpoint['requestBody'] = $this->parseOpenApi3RequestBody($operation['requestBody']); + } + } + + return $endpoint; + } + + /** + * Generate operation ID from method and path + */ + protected function generateOperationId(string $method, string $path): string + { + // Convert path to camelCase + $path = str_replace(['{', '}'], '', $path); + $parts = explode('/', trim($path, '/')); + $parts = array_map(function ($part) { + return ucfirst(str_replace('-', '', ucwords($part, '-'))); + }, $parts); + + return $method.implode('', $parts); + } + + /** + * Parse Swagger 2.0 parameters + */ + protected function parseSwagger2Parameters(array $parameters): array + { + $parsed = []; + + foreach ($parameters as $param) { + if (($param['in'] ?? '') === 'body') { + continue; // Body parameters handled separately + } + + $parsed[] = [ + 'name' => $param['name'] ?? '', + 'in' => $param['in'] ?? 'query', + 'required' => $param['required'] ?? false, + 'description' => $param['description'] ?? '', + 'type' => $param['type'] ?? 'string', + 'schema' => $this->convertSwagger2TypeToJsonSchema($param), + ]; + } + + return $parsed; + } + + /** + * Parse OpenAPI 3.0 parameters + */ + protected function parseOpenApi3Parameters(array $parameters): array + { + $parsed = []; + + foreach ($parameters as $param) { + $parsed[] = [ + 'name' => $param['name'] ?? '', + 'in' => $param['in'] ?? 'query', + 'required' => $param['required'] ?? false, + 'description' => $param['description'] ?? '', + 'schema' => $param['schema'] ?? ['type' => 'string'], + ]; + } + + return $parsed; + } + + /** + * Convert Swagger 2.0 type to JSON Schema + */ + protected function convertSwagger2TypeToJsonSchema(array $param): array + { + $schema = [ + 'type' => $param['type'] ?? 'string', + ]; + + if (isset($param['format'])) { + $schema['format'] = $param['format']; + } + + if (isset($param['enum'])) { + $schema['enum'] = $param['enum']; + } + + if (isset($param['minimum'])) { + $schema['minimum'] = $param['minimum']; + } + + if (isset($param['maximum'])) { + $schema['maximum'] = $param['maximum']; + } + + if ($schema['type'] === 'array' && isset($param['items'])) { + $schema['items'] = $this->convertSwagger2TypeToJsonSchema($param['items']); + } + + return $schema; + } + + /** + * Convert Swagger 2.0 body parameter to schema + */ + protected function convertSwagger2BodyToSchema(array $param): array + { + return [ + 'description' => $param['description'] ?? '', + 'required' => $param['required'] ?? false, + 'schema' => $param['schema'] ?? ['type' => 'object'], + ]; + } + + /** + * Parse OpenAPI 3.0 requestBody + */ + protected function parseOpenApi3RequestBody(array $requestBody): array + { + $content = $requestBody['content'] ?? []; + $jsonContent = $content['application/json'] ?? $content['*/*'] ?? null; + + return [ + 'description' => $requestBody['description'] ?? '', + 'required' => $requestBody['required'] ?? false, + 'schema' => $jsonContent['schema'] ?? ['type' => 'object'], + ]; + } +} diff --git a/src/Services/ToolGenerator.php b/src/Services/ToolGenerator.php new file mode 100644 index 0000000..304b8bf --- /dev/null +++ b/src/Services/ToolGenerator.php @@ -0,0 +1,543 @@ +generateClassName($endpoint); + $filePath = "{$outputDir}/{$className}.php"; + + // Check if file exists + if (File::exists($filePath) && ! $force) { + if (! $this->confirmOverwrite($className)) { + return null; + } + } + + // Generate tool content + $content = $this->generateToolContent($endpoint, $className, $baseUrlEnv, $outputDir); + + // Write file + File::put($filePath, $content); + + // Return fully qualified class name + $namespace = $this->getNamespaceFromPath($outputDir); + + return "{$namespace}\\{$className}"; + } + + /** + * Generate class name from endpoint + */ + protected function generateClassName(array $endpoint): string + { + $method = ucfirst(strtolower($endpoint['method'])); + $path = str_replace(['{', '}', '/', '-'], ['', '', '', ''], $endpoint['path']); + + // Convert to PascalCase + $parts = explode('/', trim($path, '/')); + $parts = array_map(function ($part) { + return Str::studly($part); + }, $parts); + + return $method.implode('', $parts).'Tool'; + } + + /** + * Generate tool name (kebab-case) + */ + protected function generateToolName(array $endpoint): string + { + $method = strtolower($endpoint['method']); + $path = str_replace(['{', '}', '/'], ['', '', '-'], $endpoint['path']); + $path = trim($path, '-'); + + return "{$method}-{$path}"; + } + + /** + * Get namespace from directory path + */ + protected function getNamespaceFromPath(string $path): string + { + // Remove base_path and any leading/trailing slashes + $relativePath = trim(str_replace(base_path(), '', $path), '/'); + + // Split into parts and convert to namespace + if (empty($relativePath)) { + return ''; + } + + $parts = explode('/', $relativePath); + + // Convert to namespace + $namespace = array_map(function ($part) { + return Str::studly($part); + }, $parts); + + return implode('\\', $namespace); + } + + /** + * Confirm file overwrite + */ + protected function confirmOverwrite(string $className): bool + { + // In non-interactive mode, skip by default + if (! app()->runningInConsole()) { + return false; + } + + $response = readline("File {$className}.php already exists. Overwrite? (y/n): "); + + return strtolower($response) === 'y'; + } + + /** + * Generate tool file content + */ + protected function generateToolContent(array $endpoint, string $className, string $baseUrlEnv, string $outputDir): string + { + // Use dynamic namespace based on output directory + $namespace = $this->getNamespaceFromPath($outputDir); + if (empty($namespace)) { + $namespace = 'App\\MCP\\Tools\\Swagger'; + } + $toolName = $this->generateToolName($endpoint); + $inputSchema = $this->generateInputSchema($endpoint); + $executeMethod = $this->generateExecuteMethod($endpoint, $baseUrlEnv); + + return <<escapeString($endpoint['description'] ?: $endpoint['summary'])}'; + } + + public function inputSchema(): array + { + return {$inputSchema}; + } + + public function execute(array \$input): array|string + { +{$executeMethod} + } + + public function annotations(): array + { + return []; + } + + public function messageType(): string + { + return 'tool_call'; + } + + protected function escapeString(string \$str): string + { + return str_replace("'", "\\'", \$str); + } +} +PHP; + } + + /** + * Escape string for PHP output + */ + protected function escapeString(string $str): string + { + return str_replace("'", "\\'", $str); + } + + /** + * Generate input schema + */ + protected function generateInputSchema(array $endpoint): string + { + $properties = []; + $required = []; + + // Add path parameters + foreach ($endpoint['parameters'] as $param) { + if ($param['in'] === 'path') { + $properties[$param['name']] = array_merge( + $param['schema'], + ['description' => $param['description']] + ); + if ($param['required']) { + $required[] = $param['name']; + } + } + } + + // Add query parameters + foreach ($endpoint['parameters'] as $param) { + if ($param['in'] === 'query') { + $properties[$param['name']] = array_merge( + $param['schema'], + ['description' => $param['description']] + ); + if ($param['required']) { + $required[] = $param['name']; + } + } + } + + // Add request body + if ($endpoint['requestBody']) { + if (isset($endpoint['requestBody']['schema']['properties'])) { + foreach ($endpoint['requestBody']['schema']['properties'] as $name => $schema) { + $properties[$name] = $schema; + } + } else { + // Single body parameter + $properties['body'] = $endpoint['requestBody']['schema']; + if ($endpoint['requestBody']['required']) { + $required[] = 'body'; + } + } + } + + $schema = [ + 'type' => 'object', + 'properties' => $properties, + ]; + + if (! empty($required)) { + $schema['required'] = $required; + } + + return $this->arrayToPhp($schema, 2); + } + + /** + * Generate execute method content + */ + protected function generateExecuteMethod(array $endpoint, string $baseUrlEnv): string + { + $method = strtolower($endpoint['method']); + $path = $endpoint['path']; + + // Build validation rules + $validationRules = $this->generateValidationRules($endpoint); + + // Build HTTP request + $httpRequest = $this->generateHttpRequest($endpoint, $baseUrlEnv); + + return <<fails()) { + return [ + 'error' => 'Validation failed', + 'errors' => \$validator->errors()->toArray(), + ]; + } + +{$httpRequest} +PHP; + } + + /** + * Generate validation rules + */ + protected function generateValidationRules(array $endpoint): string + { + $rules = []; + + foreach ($endpoint['parameters'] as $param) { + $rule = []; + + if ($param['required']) { + $rule[] = 'required'; + } else { + $rule[] = 'sometimes'; + } + + // Add type validation + switch ($param['schema']['type'] ?? 'string') { + case 'integer': + $rule[] = 'integer'; + break; + case 'number': + $rule[] = 'numeric'; + break; + case 'boolean': + $rule[] = 'boolean'; + break; + case 'array': + $rule[] = 'array'; + break; + case 'object': + $rule[] = 'array'; + break; + default: + $rule[] = 'string'; + } + + // Add enum validation + if (isset($param['schema']['enum'])) { + $rule[] = 'in:'.implode(',', $param['schema']['enum']); + } + + $rules[$param['name']] = implode('|', $rule); + } + + // Add request body validation + if ($endpoint['requestBody'] && $endpoint['requestBody']['required']) { + if (isset($endpoint['requestBody']['schema']['properties'])) { + foreach ($endpoint['requestBody']['schema']['properties'] as $name => $schema) { + $rule = ['required']; + + switch ($schema['type'] ?? 'string') { + case 'integer': + $rule[] = 'integer'; + break; + case 'number': + $rule[] = 'numeric'; + break; + case 'boolean': + $rule[] = 'boolean'; + break; + case 'array': + $rule[] = 'array'; + break; + case 'object': + $rule[] = 'array'; + break; + default: + $rule[] = 'string'; + } + + $rules[$name] = implode('|', $rule); + } + } else { + $rules['body'] = 'required'; + } + } + + return $this->arrayToPhp($rules, 2); + } + + /** + * Generate HTTP request code + */ + protected function generateHttpRequest(array $endpoint, string $baseUrlEnv): string + { + $method = strtolower($endpoint['method']); + $path = $endpoint['path']; + + // Replace path parameters + $pathParams = []; + foreach ($endpoint['parameters'] as $param) { + if ($param['in'] === 'path') { + $pathParams[] = "'{{{$param['name']}}}' => \$input['{$param['name']}'] ?? ''"; + } + } + + // Build query parameters + $queryParams = []; + foreach ($endpoint['parameters'] as $param) { + if ($param['in'] === 'query') { + $queryParams[] = "'{$param['name']}' => \$input['{$param['name']}'] ?? null"; + } + } + + // Build request body + $hasBody = $endpoint['requestBody'] !== null; + + $code = " // Build URL\n"; + $code .= " \$baseUrl = env('{$baseUrlEnv}', '');\n"; + + if (! empty($pathParams)) { + $replacements = []; + foreach ($endpoint['parameters'] as $param) { + if ($param['in'] === 'path') { + $replacements[] = "'{{{$param['name']}}}', \$input['{$param['name']}'] ?? ''"; + } + } + $code .= " \$path = str_replace(\n"; + $code .= ' ['.implode(', ', array_map(fn ($r) => explode(', ', $r)[0], $replacements))."],\n"; + $code .= ' ['.implode(', ', array_map(fn ($r) => explode(', ', $r)[1], $replacements))."],\n"; + $code .= " '{$path}'\n"; + $code .= " );\n"; + } else { + $code .= " \$path = '{$path}';\n"; + } + + $code .= " \$url = rtrim(\$baseUrl, '/') . \$path;\n\n"; + + // Add authentication if needed + if (! empty($endpoint['security'])) { + $code .= $this->generateAuthenticationCode($endpoint); + } + + $code .= " // Make HTTP request\n"; + $code .= " try {\n"; + $code .= ' $response = Http::'; + + // Add authentication headers + if (! empty($endpoint['security'])) { + $code .= 'withHeaders($headers)->'; + } + + // Add timeout + $code .= 'timeout(30)->'; + + // Add method and parameters + if ($method === 'get' || $method === 'delete' || $method === 'head') { + $code .= "{$method}(\$url"; + if (! empty($queryParams)) { + $code .= ', ['.implode(', ', $queryParams).']'; + } + $code .= ");\n"; + } else { + $code .= "{$method}(\$url"; + if ($hasBody) { + if (isset($endpoint['requestBody']['schema']['properties'])) { + // Multiple body parameters + $bodyData = []; + foreach ($endpoint['requestBody']['schema']['properties'] as $name => $schema) { + $bodyData[] = "'{$name}' => \$input['{$name}'] ?? null"; + } + $code .= ', ['.implode(', ', $bodyData).']'; + } else { + // Single body parameter + $code .= ", \$input['body'] ?? []"; + } + } + if (! empty($queryParams)) { + $code .= ', ['.implode(', ', $queryParams).']'; + } + $code .= ");\n"; + } + + $code .= "\n"; + $code .= " if (\$response->successful()) {\n"; + $code .= " return \$response->json() ?: \$response->body();\n"; + $code .= " }\n\n"; + $code .= " return [\n"; + $code .= " 'error' => 'Request failed',\n"; + $code .= " 'status' => \$response->status(),\n"; + $code .= " 'message' => \$response->body(),\n"; + $code .= " ];\n"; + $code .= " } catch (\\Exception \$e) {\n"; + $code .= " return [\n"; + $code .= " 'error' => 'Request exception',\n"; + $code .= " 'message' => \$e->getMessage(),\n"; + $code .= " ];\n"; + $code .= ' }'; + + return $code; + } + + /** + * Generate authentication code + */ + protected function generateAuthenticationCode(array $endpoint): string + { + $code = " // Setup authentication\n"; + $code .= " \$headers = [];\n"; + + foreach ($endpoint['security'] as $security) { + foreach ($security as $name => $scopes) { + if (isset($endpoint['securityDefinitions'][$name])) { + $def = $endpoint['securityDefinitions'][$name]; + + if ($def['type'] === 'apiKey') { + $envVar = 'SWAGGER_API_KEY_'.strtoupper(str_replace('-', '_', $name)); + + if ($def['in'] === 'header') { + $code .= " \$headers['{$def['name']}'] = env('{$envVar}', '');\n"; + } + // Query parameter API keys would be added to query params + } elseif ($def['type'] === 'oauth2' || $def['type'] === 'http') { + $envVar = 'SWAGGER_BEARER_TOKEN'; + $code .= " \$headers['Authorization'] = 'Bearer ' . env('{$envVar}', '');\n"; + } + } + } + } + + $code .= "\n"; + + return $code; + } + + /** + * Convert array to PHP code + */ + protected function arrayToPhp(array $array, int $indent = 0): string + { + if (empty($array)) { + return '[]'; + } + + $spaces = str_repeat(' ', $indent); + $innerSpaces = str_repeat(' ', $indent + 1); + + $isAssoc = array_keys($array) !== range(0, count($array) - 1); + + $code = "[\n"; + + foreach ($array as $key => $value) { + $code .= $innerSpaces; + + if ($isAssoc) { + $code .= "'".$this->escapeString((string) $key)."' => "; + } + + if (is_array($value)) { + $code .= $this->arrayToPhp($value, $indent + 1); + } elseif (is_bool($value)) { + $code .= $value ? 'true' : 'false'; + } elseif (is_null($value)) { + $code .= 'null'; + } elseif (is_numeric($value)) { + $code .= $value; + } else { + $code .= "'".$this->escapeString((string) $value)."'"; + } + + $code .= ",\n"; + } + + $code .= $spaces.']'; + + return $code; + } +} diff --git a/tests/fixtures/openapi-sample.json b/tests/fixtures/openapi-sample.json new file mode 100644 index 0000000..d83c3cb --- /dev/null +++ b/tests/fixtures/openapi-sample.json @@ -0,0 +1,211 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Sample OpenAPI 3.0 API", + "version": "1.0.0", + "description": "A sample OpenAPI 3.0 specification for testing" + }, + "servers": [ + { + "url": "https://api.example.com/v1" + } + ], + "components": { + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT" + }, + "apiKey": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key" + } + } + }, + "paths": { + "/products": { + "get": { + "operationId": "listProducts", + "summary": "List products", + "description": "Get a list of products with optional filtering", + "parameters": [ + { + "name": "category", + "in": "query", + "description": "Filter by category", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "minPrice", + "in": "query", + "description": "Minimum price filter", + "required": false, + "schema": { + "type": "number", + "format": "float" + } + }, + { + "name": "maxPrice", + "in": "query", + "description": "Maximum price filter", + "required": false, + "schema": { + "type": "number", + "format": "float" + } + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "List of products", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object" + } + } + } + } + } + } + }, + "post": { + "operationId": "createProduct", + "summary": "Create a product", + "description": "Add a new product to the catalog", + "requestBody": { + "required": true, + "description": "Product data", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Product name" + }, + "description": { + "type": "string", + "description": "Product description" + }, + "price": { + "type": "number", + "format": "float", + "description": "Product price" + }, + "category": { + "type": "string", + "description": "Product category" + }, + "stock": { + "type": "integer", + "description": "Stock quantity" + } + }, + "required": ["name", "price"] + } + } + } + }, + "security": [ + { + "apiKey": [] + } + ], + "responses": { + "201": { + "description": "Product created" + } + } + } + }, + "/products/{productId}": { + "get": { + "operationId": "getProduct", + "summary": "Get product details", + "description": "Retrieve detailed information about a specific product", + "parameters": [ + { + "name": "productId", + "in": "path", + "required": true, + "description": "Product identifier", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Product details" + }, + "404": { + "description": "Product not found" + } + } + }, + "patch": { + "operationId": "updateProduct", + "summary": "Update product", + "description": "Update product information", + "parameters": [ + { + "name": "productId", + "in": "path", + "required": true, + "description": "Product identifier", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "price": { + "type": "number", + "format": "float" + }, + "stock": { + "type": "integer" + } + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Product updated" + } + } + } + } + } +} \ No newline at end of file diff --git a/tests/fixtures/swagger-sample.json b/tests/fixtures/swagger-sample.json new file mode 100644 index 0000000..6f690b6 --- /dev/null +++ b/tests/fixtures/swagger-sample.json @@ -0,0 +1,213 @@ +{ + "swagger": "2.0", + "info": { + "title": "Sample API", + "version": "1.0.0", + "description": "A sample API for testing MCP tool generation" + }, + "host": "api.example.com", + "basePath": "/v1", + "schemes": ["https"], + "securityDefinitions": { + "bearer": { + "type": "apiKey", + "name": "Authorization", + "in": "header" + }, + "apiKey": { + "type": "apiKey", + "name": "X-API-Key", + "in": "header" + } + }, + "paths": { + "/users": { + "get": { + "operationId": "getUsers", + "summary": "List all users", + "description": "Retrieve a list of all users in the system", + "parameters": [ + { + "name": "page", + "in": "query", + "description": "Page number for pagination", + "required": false, + "type": "integer" + }, + { + "name": "limit", + "in": "query", + "description": "Number of items per page", + "required": false, + "type": "integer", + "minimum": 1, + "maximum": 100 + }, + { + "name": "status", + "in": "query", + "description": "Filter by user status", + "required": false, + "type": "string", + "enum": ["active", "inactive", "pending"] + } + ], + "security": [ + { + "bearer": [] + } + ], + "responses": { + "200": { + "description": "Successful response" + } + } + }, + "post": { + "operationId": "createUser", + "summary": "Create a new user", + "description": "Create a new user in the system", + "parameters": [ + { + "name": "body", + "in": "body", + "description": "User object", + "required": true, + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "User's full name" + }, + "email": { + "type": "string", + "format": "email", + "description": "User's email address" + }, + "role": { + "type": "string", + "enum": ["admin", "user", "moderator"], + "description": "User's role" + } + }, + "required": ["name", "email"] + } + } + ], + "security": [ + { + "apiKey": [] + } + ], + "responses": { + "201": { + "description": "User created successfully" + } + } + } + }, + "/users/{id}": { + "get": { + "operationId": "getUserById", + "summary": "Get user by ID", + "description": "Retrieve a specific user by their ID", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "User ID", + "required": true, + "type": "string" + } + ], + "security": [ + { + "bearer": [] + } + ], + "responses": { + "200": { + "description": "Successful response" + }, + "404": { + "description": "User not found" + } + } + }, + "put": { + "operationId": "updateUser", + "summary": "Update user", + "description": "Update an existing user's information", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "User ID", + "required": true, + "type": "string" + }, + { + "name": "body", + "in": "body", + "description": "Updated user object", + "required": true, + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "User's full name" + }, + "email": { + "type": "string", + "format": "email", + "description": "User's email address" + }, + "status": { + "type": "string", + "enum": ["active", "inactive"], + "description": "User's status" + } + } + } + } + ], + "security": [ + { + "bearer": [] + } + ], + "responses": { + "200": { + "description": "User updated successfully" + } + } + }, + "delete": { + "operationId": "deleteUser", + "summary": "Delete user", + "description": "Delete a user from the system", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "User ID", + "required": true, + "type": "string" + } + ], + "security": [ + { + "bearer": [] + } + ], + "responses": { + "204": { + "description": "User deleted successfully" + } + } + } + } + } +} \ No newline at end of file From 209fb4c0b51c6e2def0e06e687c01bd56bca0bb1 Mon Sep 17 00:00:00 2001 From: Sangrak Choi Date: Fri, 8 Aug 2025 23:14:26 +0900 Subject: [PATCH 2/7] Revert "feat(swagger): add automatic MCP tool generation from Swagger/OpenAPI specs" This reverts commit 4868901226a79bbd0e6f3811276092fe8bc9274b. --- README.md | 41 -- .../Commands/GenerateFromSwaggerCommand.php | 250 -------- src/LaravelMcpServerServiceProvider.php | 2 - src/Services/SwaggerParser.php | 269 --------- src/Services/ToolGenerator.php | 543 ------------------ tests/fixtures/openapi-sample.json | 211 ------- tests/fixtures/swagger-sample.json | 213 ------- 7 files changed, 1529 deletions(-) delete mode 100644 src/Console/Commands/GenerateFromSwaggerCommand.php delete mode 100644 src/Services/SwaggerParser.php delete mode 100644 src/Services/ToolGenerator.php delete mode 100644 tests/fixtures/openapi-sample.json delete mode 100644 tests/fixtures/swagger-sample.json diff --git a/README.md b/README.md index 07d16d2..1540322 100644 --- a/README.md +++ b/README.md @@ -310,47 +310,6 @@ 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 - -You can automatically generate MCP tools from Swagger/OpenAPI specifications: - -```bash -# From local file -php artisan mcp:generate-from-swagger path/to/swagger.json - -# From URL -php artisan mcp:generate-from-swagger https://api.example.com/swagger.json -``` - -This command: - -- Accepts both local file paths and URLs -- Supports both Swagger 2.0 and OpenAPI 3.0 formats -- Generates one MCP tool per endpoint (path + method combination) -- Includes HTTP client implementation using Laravel's Http facade -- Handles authentication (Bearer tokens, API keys) -- Auto-registers tools in configuration - -Options: -- `--output-dir`: Custom output directory (default: `app/MCP/Tools/Swagger`) -- `--base-url-env`: Environment variable name for API base URL (default: `SWAGGER_API_BASE_URL`) -- `--no-register`: Skip auto-registration in config -- `--force`: Overwrite existing files without confirmation - -Examples: -```bash -# Generate from local file -php artisan mcp:generate-from-swagger api-spec.json \ - --output-dir=app/MCP/Tools/API \ - --base-url-env=API_BASE_URL \ - --force - -# Generate from URL (e.g., OP.GG API) -php artisan mcp:generate-from-swagger https://data.op.gg/lol/swagger.json \ - --output-dir=app/MCP/Tools/OPGG \ - --base-url-env=OPGG_API_BASE_URL -``` - You can also manually create and register tools in `config/mcp-server.php`: ```php diff --git a/src/Console/Commands/GenerateFromSwaggerCommand.php b/src/Console/Commands/GenerateFromSwaggerCommand.php deleted file mode 100644 index 229b9e6..0000000 --- a/src/Console/Commands/GenerateFromSwaggerCommand.php +++ /dev/null @@ -1,250 +0,0 @@ -parser = $parser; - $this->generator = $generator; - } - - public function handle(): int - { - $source = $this->argument('source'); - - // Check if source is a URL - if (filter_var($source, FILTER_VALIDATE_URL)) { - $this->info('Downloading Swagger/OpenAPI specification from URL...'); - $json = $this->downloadFromUrl($source); - - if ($json === null) { - return Command::FAILURE; - } - } else { - // Treat as file path - if (! File::exists($source)) { - $this->error("File not found: {$source}"); - - return Command::FAILURE; - } - - $json = File::get($source); - } - - // Parse JSON - $spec = json_decode($json, true); - - if (json_last_error() !== JSON_ERROR_NONE) { - $this->error('Invalid JSON file: '.json_last_error_msg()); - - return Command::FAILURE; - } - - $this->info('Parsing Swagger/OpenAPI specification...'); - - try { - // Parse the specification - $endpoints = $this->parser->parse($spec); - - if (empty($endpoints)) { - $this->warn('No endpoints found in the specification.'); - - return Command::SUCCESS; - } - - $endpointCount = count($endpoints); - $this->info("Found {$endpointCount} endpoints to process."); - - // Setup output directory - $outputDir = base_path($this->option('output-dir')); - if (! File::exists($outputDir)) { - File::makeDirectory($outputDir, 0755, true); - } - - // Generate tools - $generatedTools = []; - $progressBar = $this->output->createProgressBar(count($endpoints)); - $progressBar->start(); - - foreach ($endpoints as $index => $endpoint) { - $this->newLine(); - $current = $index + 1; - $total = count($endpoints); - $this->info("Processing endpoint [{$current}/{$total}]: {$endpoint['method']} {$endpoint['path']}"); - - $toolClass = $this->generator->generateTool( - $endpoint, - $outputDir, - $this->option('base-url-env'), - $this->option('force') - ); - - if ($toolClass) { - $generatedTools[] = $toolClass; - } - - $progressBar->advance(); - } - - $progressBar->finish(); - $this->newLine(2); - - // Register tools if needed - if (! $this->option('no-register') && ! empty($generatedTools)) { - $this->info('Registering tools in configuration...'); - $registered = $this->registerTools($generatedTools); - - if ($registered > 0) { - $this->info("Successfully registered {$registered} tools."); - } - } - - // Display summary - $this->displaySummary(count($endpoints), count($generatedTools)); - - // Generate .env.example entry - $this->generateEnvExample($this->option('base-url-env')); - - return Command::SUCCESS; - - } catch (\Exception $e) { - $this->error('Error processing specification: '.$e->getMessage()); - - return Command::FAILURE; - } - } - - protected function registerTools(array $toolClasses): int - { - $configPath = config_path('mcp-server.php'); - - if (! File::exists($configPath)) { - $this->warn('Config file not found. Please publish the configuration first.'); - - return 0; - } - - $config = File::get($configPath); - $registered = 0; - - // Find the tools array - if (preg_match('/\'tools\'\s*=>\s*\[/', $config, $matches, PREG_OFFSET_CAPTURE)) { - $insertPosition = $matches[0][1] + strlen($matches[0][0]); - - // Check for existing tools and find the right position - $afterBracket = substr($config, $insertPosition); - - // Add comment for generated tools - $toolsToAdd = "\n // Generated from Swagger"; - - foreach ($toolClasses as $className) { - // Check if already registered - if (strpos($config, $className) === false) { - $toolsToAdd .= "\n \\{$className}::class,"; - $registered++; - } else { - $this->warn("Tool already registered: {$className}"); - } - } - - if ($registered > 0) { - // Insert the new tools - $newConfig = substr($config, 0, $insertPosition).$toolsToAdd.substr($config, $insertPosition); - File::put($configPath, $newConfig); - } - } - - return $registered; - } - - protected function displaySummary(int $totalEndpoints, int $generatedTools): void - { - $this->newLine(); - $this->info('=== Generation Summary ==='); - $this->line("Total endpoints processed: {$totalEndpoints}"); - $this->line("Tools generated: {$generatedTools}"); - - if ($generatedTools < $totalEndpoints) { - $skipped = $totalEndpoints - $generatedTools; - $this->warn("Tools skipped: {$skipped} (due to conflicts or errors)"); - } - } - - protected function generateEnvExample(string $envVar): void - { - $envExamplePath = base_path('.env.example'); - - if (! File::exists($envExamplePath)) { - return; - } - - $envContent = File::get($envExamplePath); - - if (strpos($envContent, $envVar) === false) { - $addition = "\n# Swagger API Configuration\n{$envVar}=https://api.example.com\n"; - File::append($envExamplePath, $addition); - $this->info("Added {$envVar} to .env.example"); - } - } - - protected function downloadFromUrl(string $url): ?string - { - try { - $response = Http::timeout(30) - ->withHeaders([ - 'Accept' => 'application/json', - 'User-Agent' => 'Laravel-MCP-Server/1.0', - ]) - ->get($url); - - if ($response->successful()) { - $this->info('Successfully downloaded specification from URL'); - - return $response->body(); - } - - $this->error("Failed to download from URL. Status: {$response->status()}"); - - if ($response->status() === 404) { - $this->error('The URL was not found. Please check the URL and try again.'); - } elseif ($response->status() === 403) { - $this->error('Access denied. The URL may require authentication.'); - } elseif ($response->status() >= 500) { - $this->error('Server error. Please try again later.'); - } - - return null; - } catch (\Exception $e) { - $this->error('Error downloading from URL: '.$e->getMessage()); - - if (str_contains($e->getMessage(), 'cURL error 6')) { - $this->error('Could not resolve host. Please check your internet connection and the URL.'); - } elseif (str_contains($e->getMessage(), 'cURL error 28')) { - $this->error('Request timed out. The server may be slow or unreachable.'); - } - - return null; - } - } -} diff --git a/src/LaravelMcpServerServiceProvider.php b/src/LaravelMcpServerServiceProvider.php index b57fccb..a8b399c 100644 --- a/src/LaravelMcpServerServiceProvider.php +++ b/src/LaravelMcpServerServiceProvider.php @@ -4,7 +4,6 @@ use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Route; -use OPGG\LaravelMcpServer\Console\Commands\GenerateFromSwaggerCommand; use OPGG\LaravelMcpServer\Console\Commands\MakeMcpNotificationCommand; use OPGG\LaravelMcpServer\Console\Commands\MakeMcpPromptCommand; use OPGG\LaravelMcpServer\Console\Commands\MakeMcpResourceCommand; @@ -34,7 +33,6 @@ public function configurePackage(Package $package): void ->name('laravel-mcp-server') ->hasConfigFile('mcp-server') ->hasCommands([ - GenerateFromSwaggerCommand::class, MakeMcpToolCommand::class, MakeMcpResourceCommand::class, MakeMcpResourceTemplateCommand::class, diff --git a/src/Services/SwaggerParser.php b/src/Services/SwaggerParser.php deleted file mode 100644 index d6f765e..0000000 --- a/src/Services/SwaggerParser.php +++ /dev/null @@ -1,269 +0,0 @@ -detectVersion($spec); - - if ($version === '2.0') { - return $this->parseSwagger2($spec); - } elseif (str_starts_with($version, '3.')) { - return $this->parseOpenApi3($spec); - } - - throw new \InvalidArgumentException("Unsupported specification version: {$version}"); - } - - /** - * Detect specification version - */ - protected function detectVersion(array $spec): string - { - if (isset($spec['swagger'])) { - return $spec['swagger']; - } - - if (isset($spec['openapi'])) { - return $spec['openapi']; - } - - throw new \InvalidArgumentException('Unable to detect Swagger/OpenAPI version'); - } - - /** - * Parse Swagger 2.0 specification - */ - protected function parseSwagger2(array $spec): array - { - $endpoints = []; - $basePath = $spec['basePath'] ?? ''; - $host = $spec['host'] ?? ''; - $schemes = $spec['schemes'] ?? ['https']; - $baseUrl = "{$schemes[0]}://{$host}{$basePath}"; - - $globalSecurity = $spec['security'] ?? []; - $securityDefinitions = $spec['securityDefinitions'] ?? []; - - foreach ($spec['paths'] ?? [] as $path => $pathItem) { - foreach ($pathItem as $method => $operation) { - if (in_array($method, ['get', 'post', 'put', 'patch', 'delete', 'head', 'options'])) { - $endpoints[] = $this->parseEndpoint( - $path, - $method, - $operation, - $baseUrl, - $globalSecurity, - $securityDefinitions, - '2.0' - ); - } - } - } - - return $endpoints; - } - - /** - * Parse OpenAPI 3.0 specification - */ - protected function parseOpenApi3(array $spec): array - { - $endpoints = []; - $servers = $spec['servers'] ?? [['url' => '']]; - $baseUrl = $servers[0]['url'] ?? ''; - - $globalSecurity = $spec['security'] ?? []; - $securitySchemes = $spec['components']['securitySchemes'] ?? []; - - foreach ($spec['paths'] ?? [] as $path => $pathItem) { - foreach ($pathItem as $method => $operation) { - if (in_array($method, ['get', 'post', 'put', 'patch', 'delete', 'head', 'options'])) { - $endpoints[] = $this->parseEndpoint( - $path, - $method, - $operation, - $baseUrl, - $globalSecurity, - $securitySchemes, - '3.0' - ); - } - } - } - - return $endpoints; - } - - /** - * Parse individual endpoint - */ - protected function parseEndpoint( - string $path, - string $method, - array $operation, - string $baseUrl, - array $globalSecurity, - array $securityDefinitions, - string $version - ): array { - $endpoint = [ - 'path' => $path, - 'method' => strtoupper($method), - 'operationId' => $operation['operationId'] ?? $this->generateOperationId($method, $path), - 'summary' => $operation['summary'] ?? '', - 'description' => $operation['description'] ?? '', - 'baseUrl' => $baseUrl, - 'parameters' => [], - 'requestBody' => null, - 'security' => $operation['security'] ?? $globalSecurity, - 'securityDefinitions' => $securityDefinitions, - ]; - - // Parse parameters - if ($version === '2.0') { - $endpoint['parameters'] = $this->parseSwagger2Parameters($operation['parameters'] ?? []); - - // Handle body parameters separately in Swagger 2.0 - foreach ($operation['parameters'] ?? [] as $param) { - if (($param['in'] ?? '') === 'body') { - $endpoint['requestBody'] = $this->convertSwagger2BodyToSchema($param); - } - } - } else { - $endpoint['parameters'] = $this->parseOpenApi3Parameters($operation['parameters'] ?? []); - - // Handle requestBody in OpenAPI 3.0 - if (isset($operation['requestBody'])) { - $endpoint['requestBody'] = $this->parseOpenApi3RequestBody($operation['requestBody']); - } - } - - return $endpoint; - } - - /** - * Generate operation ID from method and path - */ - protected function generateOperationId(string $method, string $path): string - { - // Convert path to camelCase - $path = str_replace(['{', '}'], '', $path); - $parts = explode('/', trim($path, '/')); - $parts = array_map(function ($part) { - return ucfirst(str_replace('-', '', ucwords($part, '-'))); - }, $parts); - - return $method.implode('', $parts); - } - - /** - * Parse Swagger 2.0 parameters - */ - protected function parseSwagger2Parameters(array $parameters): array - { - $parsed = []; - - foreach ($parameters as $param) { - if (($param['in'] ?? '') === 'body') { - continue; // Body parameters handled separately - } - - $parsed[] = [ - 'name' => $param['name'] ?? '', - 'in' => $param['in'] ?? 'query', - 'required' => $param['required'] ?? false, - 'description' => $param['description'] ?? '', - 'type' => $param['type'] ?? 'string', - 'schema' => $this->convertSwagger2TypeToJsonSchema($param), - ]; - } - - return $parsed; - } - - /** - * Parse OpenAPI 3.0 parameters - */ - protected function parseOpenApi3Parameters(array $parameters): array - { - $parsed = []; - - foreach ($parameters as $param) { - $parsed[] = [ - 'name' => $param['name'] ?? '', - 'in' => $param['in'] ?? 'query', - 'required' => $param['required'] ?? false, - 'description' => $param['description'] ?? '', - 'schema' => $param['schema'] ?? ['type' => 'string'], - ]; - } - - return $parsed; - } - - /** - * Convert Swagger 2.0 type to JSON Schema - */ - protected function convertSwagger2TypeToJsonSchema(array $param): array - { - $schema = [ - 'type' => $param['type'] ?? 'string', - ]; - - if (isset($param['format'])) { - $schema['format'] = $param['format']; - } - - if (isset($param['enum'])) { - $schema['enum'] = $param['enum']; - } - - if (isset($param['minimum'])) { - $schema['minimum'] = $param['minimum']; - } - - if (isset($param['maximum'])) { - $schema['maximum'] = $param['maximum']; - } - - if ($schema['type'] === 'array' && isset($param['items'])) { - $schema['items'] = $this->convertSwagger2TypeToJsonSchema($param['items']); - } - - return $schema; - } - - /** - * Convert Swagger 2.0 body parameter to schema - */ - protected function convertSwagger2BodyToSchema(array $param): array - { - return [ - 'description' => $param['description'] ?? '', - 'required' => $param['required'] ?? false, - 'schema' => $param['schema'] ?? ['type' => 'object'], - ]; - } - - /** - * Parse OpenAPI 3.0 requestBody - */ - protected function parseOpenApi3RequestBody(array $requestBody): array - { - $content = $requestBody['content'] ?? []; - $jsonContent = $content['application/json'] ?? $content['*/*'] ?? null; - - return [ - 'description' => $requestBody['description'] ?? '', - 'required' => $requestBody['required'] ?? false, - 'schema' => $jsonContent['schema'] ?? ['type' => 'object'], - ]; - } -} diff --git a/src/Services/ToolGenerator.php b/src/Services/ToolGenerator.php deleted file mode 100644 index 304b8bf..0000000 --- a/src/Services/ToolGenerator.php +++ /dev/null @@ -1,543 +0,0 @@ -generateClassName($endpoint); - $filePath = "{$outputDir}/{$className}.php"; - - // Check if file exists - if (File::exists($filePath) && ! $force) { - if (! $this->confirmOverwrite($className)) { - return null; - } - } - - // Generate tool content - $content = $this->generateToolContent($endpoint, $className, $baseUrlEnv, $outputDir); - - // Write file - File::put($filePath, $content); - - // Return fully qualified class name - $namespace = $this->getNamespaceFromPath($outputDir); - - return "{$namespace}\\{$className}"; - } - - /** - * Generate class name from endpoint - */ - protected function generateClassName(array $endpoint): string - { - $method = ucfirst(strtolower($endpoint['method'])); - $path = str_replace(['{', '}', '/', '-'], ['', '', '', ''], $endpoint['path']); - - // Convert to PascalCase - $parts = explode('/', trim($path, '/')); - $parts = array_map(function ($part) { - return Str::studly($part); - }, $parts); - - return $method.implode('', $parts).'Tool'; - } - - /** - * Generate tool name (kebab-case) - */ - protected function generateToolName(array $endpoint): string - { - $method = strtolower($endpoint['method']); - $path = str_replace(['{', '}', '/'], ['', '', '-'], $endpoint['path']); - $path = trim($path, '-'); - - return "{$method}-{$path}"; - } - - /** - * Get namespace from directory path - */ - protected function getNamespaceFromPath(string $path): string - { - // Remove base_path and any leading/trailing slashes - $relativePath = trim(str_replace(base_path(), '', $path), '/'); - - // Split into parts and convert to namespace - if (empty($relativePath)) { - return ''; - } - - $parts = explode('/', $relativePath); - - // Convert to namespace - $namespace = array_map(function ($part) { - return Str::studly($part); - }, $parts); - - return implode('\\', $namespace); - } - - /** - * Confirm file overwrite - */ - protected function confirmOverwrite(string $className): bool - { - // In non-interactive mode, skip by default - if (! app()->runningInConsole()) { - return false; - } - - $response = readline("File {$className}.php already exists. Overwrite? (y/n): "); - - return strtolower($response) === 'y'; - } - - /** - * Generate tool file content - */ - protected function generateToolContent(array $endpoint, string $className, string $baseUrlEnv, string $outputDir): string - { - // Use dynamic namespace based on output directory - $namespace = $this->getNamespaceFromPath($outputDir); - if (empty($namespace)) { - $namespace = 'App\\MCP\\Tools\\Swagger'; - } - $toolName = $this->generateToolName($endpoint); - $inputSchema = $this->generateInputSchema($endpoint); - $executeMethod = $this->generateExecuteMethod($endpoint, $baseUrlEnv); - - return <<escapeString($endpoint['description'] ?: $endpoint['summary'])}'; - } - - public function inputSchema(): array - { - return {$inputSchema}; - } - - public function execute(array \$input): array|string - { -{$executeMethod} - } - - public function annotations(): array - { - return []; - } - - public function messageType(): string - { - return 'tool_call'; - } - - protected function escapeString(string \$str): string - { - return str_replace("'", "\\'", \$str); - } -} -PHP; - } - - /** - * Escape string for PHP output - */ - protected function escapeString(string $str): string - { - return str_replace("'", "\\'", $str); - } - - /** - * Generate input schema - */ - protected function generateInputSchema(array $endpoint): string - { - $properties = []; - $required = []; - - // Add path parameters - foreach ($endpoint['parameters'] as $param) { - if ($param['in'] === 'path') { - $properties[$param['name']] = array_merge( - $param['schema'], - ['description' => $param['description']] - ); - if ($param['required']) { - $required[] = $param['name']; - } - } - } - - // Add query parameters - foreach ($endpoint['parameters'] as $param) { - if ($param['in'] === 'query') { - $properties[$param['name']] = array_merge( - $param['schema'], - ['description' => $param['description']] - ); - if ($param['required']) { - $required[] = $param['name']; - } - } - } - - // Add request body - if ($endpoint['requestBody']) { - if (isset($endpoint['requestBody']['schema']['properties'])) { - foreach ($endpoint['requestBody']['schema']['properties'] as $name => $schema) { - $properties[$name] = $schema; - } - } else { - // Single body parameter - $properties['body'] = $endpoint['requestBody']['schema']; - if ($endpoint['requestBody']['required']) { - $required[] = 'body'; - } - } - } - - $schema = [ - 'type' => 'object', - 'properties' => $properties, - ]; - - if (! empty($required)) { - $schema['required'] = $required; - } - - return $this->arrayToPhp($schema, 2); - } - - /** - * Generate execute method content - */ - protected function generateExecuteMethod(array $endpoint, string $baseUrlEnv): string - { - $method = strtolower($endpoint['method']); - $path = $endpoint['path']; - - // Build validation rules - $validationRules = $this->generateValidationRules($endpoint); - - // Build HTTP request - $httpRequest = $this->generateHttpRequest($endpoint, $baseUrlEnv); - - return <<fails()) { - return [ - 'error' => 'Validation failed', - 'errors' => \$validator->errors()->toArray(), - ]; - } - -{$httpRequest} -PHP; - } - - /** - * Generate validation rules - */ - protected function generateValidationRules(array $endpoint): string - { - $rules = []; - - foreach ($endpoint['parameters'] as $param) { - $rule = []; - - if ($param['required']) { - $rule[] = 'required'; - } else { - $rule[] = 'sometimes'; - } - - // Add type validation - switch ($param['schema']['type'] ?? 'string') { - case 'integer': - $rule[] = 'integer'; - break; - case 'number': - $rule[] = 'numeric'; - break; - case 'boolean': - $rule[] = 'boolean'; - break; - case 'array': - $rule[] = 'array'; - break; - case 'object': - $rule[] = 'array'; - break; - default: - $rule[] = 'string'; - } - - // Add enum validation - if (isset($param['schema']['enum'])) { - $rule[] = 'in:'.implode(',', $param['schema']['enum']); - } - - $rules[$param['name']] = implode('|', $rule); - } - - // Add request body validation - if ($endpoint['requestBody'] && $endpoint['requestBody']['required']) { - if (isset($endpoint['requestBody']['schema']['properties'])) { - foreach ($endpoint['requestBody']['schema']['properties'] as $name => $schema) { - $rule = ['required']; - - switch ($schema['type'] ?? 'string') { - case 'integer': - $rule[] = 'integer'; - break; - case 'number': - $rule[] = 'numeric'; - break; - case 'boolean': - $rule[] = 'boolean'; - break; - case 'array': - $rule[] = 'array'; - break; - case 'object': - $rule[] = 'array'; - break; - default: - $rule[] = 'string'; - } - - $rules[$name] = implode('|', $rule); - } - } else { - $rules['body'] = 'required'; - } - } - - return $this->arrayToPhp($rules, 2); - } - - /** - * Generate HTTP request code - */ - protected function generateHttpRequest(array $endpoint, string $baseUrlEnv): string - { - $method = strtolower($endpoint['method']); - $path = $endpoint['path']; - - // Replace path parameters - $pathParams = []; - foreach ($endpoint['parameters'] as $param) { - if ($param['in'] === 'path') { - $pathParams[] = "'{{{$param['name']}}}' => \$input['{$param['name']}'] ?? ''"; - } - } - - // Build query parameters - $queryParams = []; - foreach ($endpoint['parameters'] as $param) { - if ($param['in'] === 'query') { - $queryParams[] = "'{$param['name']}' => \$input['{$param['name']}'] ?? null"; - } - } - - // Build request body - $hasBody = $endpoint['requestBody'] !== null; - - $code = " // Build URL\n"; - $code .= " \$baseUrl = env('{$baseUrlEnv}', '');\n"; - - if (! empty($pathParams)) { - $replacements = []; - foreach ($endpoint['parameters'] as $param) { - if ($param['in'] === 'path') { - $replacements[] = "'{{{$param['name']}}}', \$input['{$param['name']}'] ?? ''"; - } - } - $code .= " \$path = str_replace(\n"; - $code .= ' ['.implode(', ', array_map(fn ($r) => explode(', ', $r)[0], $replacements))."],\n"; - $code .= ' ['.implode(', ', array_map(fn ($r) => explode(', ', $r)[1], $replacements))."],\n"; - $code .= " '{$path}'\n"; - $code .= " );\n"; - } else { - $code .= " \$path = '{$path}';\n"; - } - - $code .= " \$url = rtrim(\$baseUrl, '/') . \$path;\n\n"; - - // Add authentication if needed - if (! empty($endpoint['security'])) { - $code .= $this->generateAuthenticationCode($endpoint); - } - - $code .= " // Make HTTP request\n"; - $code .= " try {\n"; - $code .= ' $response = Http::'; - - // Add authentication headers - if (! empty($endpoint['security'])) { - $code .= 'withHeaders($headers)->'; - } - - // Add timeout - $code .= 'timeout(30)->'; - - // Add method and parameters - if ($method === 'get' || $method === 'delete' || $method === 'head') { - $code .= "{$method}(\$url"; - if (! empty($queryParams)) { - $code .= ', ['.implode(', ', $queryParams).']'; - } - $code .= ");\n"; - } else { - $code .= "{$method}(\$url"; - if ($hasBody) { - if (isset($endpoint['requestBody']['schema']['properties'])) { - // Multiple body parameters - $bodyData = []; - foreach ($endpoint['requestBody']['schema']['properties'] as $name => $schema) { - $bodyData[] = "'{$name}' => \$input['{$name}'] ?? null"; - } - $code .= ', ['.implode(', ', $bodyData).']'; - } else { - // Single body parameter - $code .= ", \$input['body'] ?? []"; - } - } - if (! empty($queryParams)) { - $code .= ', ['.implode(', ', $queryParams).']'; - } - $code .= ");\n"; - } - - $code .= "\n"; - $code .= " if (\$response->successful()) {\n"; - $code .= " return \$response->json() ?: \$response->body();\n"; - $code .= " }\n\n"; - $code .= " return [\n"; - $code .= " 'error' => 'Request failed',\n"; - $code .= " 'status' => \$response->status(),\n"; - $code .= " 'message' => \$response->body(),\n"; - $code .= " ];\n"; - $code .= " } catch (\\Exception \$e) {\n"; - $code .= " return [\n"; - $code .= " 'error' => 'Request exception',\n"; - $code .= " 'message' => \$e->getMessage(),\n"; - $code .= " ];\n"; - $code .= ' }'; - - return $code; - } - - /** - * Generate authentication code - */ - protected function generateAuthenticationCode(array $endpoint): string - { - $code = " // Setup authentication\n"; - $code .= " \$headers = [];\n"; - - foreach ($endpoint['security'] as $security) { - foreach ($security as $name => $scopes) { - if (isset($endpoint['securityDefinitions'][$name])) { - $def = $endpoint['securityDefinitions'][$name]; - - if ($def['type'] === 'apiKey') { - $envVar = 'SWAGGER_API_KEY_'.strtoupper(str_replace('-', '_', $name)); - - if ($def['in'] === 'header') { - $code .= " \$headers['{$def['name']}'] = env('{$envVar}', '');\n"; - } - // Query parameter API keys would be added to query params - } elseif ($def['type'] === 'oauth2' || $def['type'] === 'http') { - $envVar = 'SWAGGER_BEARER_TOKEN'; - $code .= " \$headers['Authorization'] = 'Bearer ' . env('{$envVar}', '');\n"; - } - } - } - } - - $code .= "\n"; - - return $code; - } - - /** - * Convert array to PHP code - */ - protected function arrayToPhp(array $array, int $indent = 0): string - { - if (empty($array)) { - return '[]'; - } - - $spaces = str_repeat(' ', $indent); - $innerSpaces = str_repeat(' ', $indent + 1); - - $isAssoc = array_keys($array) !== range(0, count($array) - 1); - - $code = "[\n"; - - foreach ($array as $key => $value) { - $code .= $innerSpaces; - - if ($isAssoc) { - $code .= "'".$this->escapeString((string) $key)."' => "; - } - - if (is_array($value)) { - $code .= $this->arrayToPhp($value, $indent + 1); - } elseif (is_bool($value)) { - $code .= $value ? 'true' : 'false'; - } elseif (is_null($value)) { - $code .= 'null'; - } elseif (is_numeric($value)) { - $code .= $value; - } else { - $code .= "'".$this->escapeString((string) $value)."'"; - } - - $code .= ",\n"; - } - - $code .= $spaces.']'; - - return $code; - } -} diff --git a/tests/fixtures/openapi-sample.json b/tests/fixtures/openapi-sample.json deleted file mode 100644 index d83c3cb..0000000 --- a/tests/fixtures/openapi-sample.json +++ /dev/null @@ -1,211 +0,0 @@ -{ - "openapi": "3.0.0", - "info": { - "title": "Sample OpenAPI 3.0 API", - "version": "1.0.0", - "description": "A sample OpenAPI 3.0 specification for testing" - }, - "servers": [ - { - "url": "https://api.example.com/v1" - } - ], - "components": { - "securitySchemes": { - "bearerAuth": { - "type": "http", - "scheme": "bearer", - "bearerFormat": "JWT" - }, - "apiKey": { - "type": "apiKey", - "in": "header", - "name": "X-API-Key" - } - } - }, - "paths": { - "/products": { - "get": { - "operationId": "listProducts", - "summary": "List products", - "description": "Get a list of products with optional filtering", - "parameters": [ - { - "name": "category", - "in": "query", - "description": "Filter by category", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "minPrice", - "in": "query", - "description": "Minimum price filter", - "required": false, - "schema": { - "type": "number", - "format": "float" - } - }, - { - "name": "maxPrice", - "in": "query", - "description": "Maximum price filter", - "required": false, - "schema": { - "type": "number", - "format": "float" - } - } - ], - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "List of products", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "object" - } - } - } - } - } - } - }, - "post": { - "operationId": "createProduct", - "summary": "Create a product", - "description": "Add a new product to the catalog", - "requestBody": { - "required": true, - "description": "Product data", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Product name" - }, - "description": { - "type": "string", - "description": "Product description" - }, - "price": { - "type": "number", - "format": "float", - "description": "Product price" - }, - "category": { - "type": "string", - "description": "Product category" - }, - "stock": { - "type": "integer", - "description": "Stock quantity" - } - }, - "required": ["name", "price"] - } - } - } - }, - "security": [ - { - "apiKey": [] - } - ], - "responses": { - "201": { - "description": "Product created" - } - } - } - }, - "/products/{productId}": { - "get": { - "operationId": "getProduct", - "summary": "Get product details", - "description": "Retrieve detailed information about a specific product", - "parameters": [ - { - "name": "productId", - "in": "path", - "required": true, - "description": "Product identifier", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Product details" - }, - "404": { - "description": "Product not found" - } - } - }, - "patch": { - "operationId": "updateProduct", - "summary": "Update product", - "description": "Update product information", - "parameters": [ - { - "name": "productId", - "in": "path", - "required": true, - "description": "Product identifier", - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "price": { - "type": "number", - "format": "float" - }, - "stock": { - "type": "integer" - } - } - } - } - } - }, - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Product updated" - } - } - } - } - } -} \ No newline at end of file diff --git a/tests/fixtures/swagger-sample.json b/tests/fixtures/swagger-sample.json deleted file mode 100644 index 6f690b6..0000000 --- a/tests/fixtures/swagger-sample.json +++ /dev/null @@ -1,213 +0,0 @@ -{ - "swagger": "2.0", - "info": { - "title": "Sample API", - "version": "1.0.0", - "description": "A sample API for testing MCP tool generation" - }, - "host": "api.example.com", - "basePath": "/v1", - "schemes": ["https"], - "securityDefinitions": { - "bearer": { - "type": "apiKey", - "name": "Authorization", - "in": "header" - }, - "apiKey": { - "type": "apiKey", - "name": "X-API-Key", - "in": "header" - } - }, - "paths": { - "/users": { - "get": { - "operationId": "getUsers", - "summary": "List all users", - "description": "Retrieve a list of all users in the system", - "parameters": [ - { - "name": "page", - "in": "query", - "description": "Page number for pagination", - "required": false, - "type": "integer" - }, - { - "name": "limit", - "in": "query", - "description": "Number of items per page", - "required": false, - "type": "integer", - "minimum": 1, - "maximum": 100 - }, - { - "name": "status", - "in": "query", - "description": "Filter by user status", - "required": false, - "type": "string", - "enum": ["active", "inactive", "pending"] - } - ], - "security": [ - { - "bearer": [] - } - ], - "responses": { - "200": { - "description": "Successful response" - } - } - }, - "post": { - "operationId": "createUser", - "summary": "Create a new user", - "description": "Create a new user in the system", - "parameters": [ - { - "name": "body", - "in": "body", - "description": "User object", - "required": true, - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "User's full name" - }, - "email": { - "type": "string", - "format": "email", - "description": "User's email address" - }, - "role": { - "type": "string", - "enum": ["admin", "user", "moderator"], - "description": "User's role" - } - }, - "required": ["name", "email"] - } - } - ], - "security": [ - { - "apiKey": [] - } - ], - "responses": { - "201": { - "description": "User created successfully" - } - } - } - }, - "/users/{id}": { - "get": { - "operationId": "getUserById", - "summary": "Get user by ID", - "description": "Retrieve a specific user by their ID", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "User ID", - "required": true, - "type": "string" - } - ], - "security": [ - { - "bearer": [] - } - ], - "responses": { - "200": { - "description": "Successful response" - }, - "404": { - "description": "User not found" - } - } - }, - "put": { - "operationId": "updateUser", - "summary": "Update user", - "description": "Update an existing user's information", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "User ID", - "required": true, - "type": "string" - }, - { - "name": "body", - "in": "body", - "description": "Updated user object", - "required": true, - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "User's full name" - }, - "email": { - "type": "string", - "format": "email", - "description": "User's email address" - }, - "status": { - "type": "string", - "enum": ["active", "inactive"], - "description": "User's status" - } - } - } - } - ], - "security": [ - { - "bearer": [] - } - ], - "responses": { - "200": { - "description": "User updated successfully" - } - } - }, - "delete": { - "operationId": "deleteUser", - "summary": "Delete user", - "description": "Delete a user from the system", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "User ID", - "required": true, - "type": "string" - } - ], - "security": [ - { - "bearer": [] - } - ], - "responses": { - "204": { - "description": "User deleted successfully" - } - } - } - } - } -} \ No newline at end of file From 2dcbce0303635e1f9c708825c3456e0ae01ed16b Mon Sep 17 00:00:00 2001 From: Sangrak Choi Date: Sat, 9 Aug 2025 01:35:17 +0900 Subject: [PATCH 3/7] feat: add automatic MCP tool generation from Swagger/OpenAPI specs (v1.4.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces powerful automatic tool generation from any Swagger/OpenAPI specification, dramatically reducing time needed to integrate external APIs. Major features: - SwaggerParser: Supports OpenAPI 3.x and Swagger 2.0 formats - SwaggerToMcpConverter: Converts API endpoints to MCP tool parameters - MakeSwaggerMcpToolCommand: Interactive CLI with endpoint selection - Smart naming: Detects hash-like operationIds and uses path-based naming - Authentication support: API Key, Bearer Token, OAuth2 generation - API testing: Validates connectivity before tool generation - Endpoint grouping: By tags, path prefixes, or individual selection - Comprehensive test coverage for naming conversion and parsing logic 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- README.md | 107 +++- src/Console/Commands/MakeMcpToolCommand.php | 170 +++++- .../Commands/MakeSwaggerMcpToolCommand.php | 403 ++++++++++++++ src/LaravelMcpServerServiceProvider.php | 2 + src/Services/SwaggerParser/SwaggerParser.php | 381 ++++++++++++++ .../SwaggerParser/SwaggerToMcpConverter.php | 492 ++++++++++++++++++ tests/Unit/NamingConversionTest.php | 119 +++++ tests/Unit/SwaggerParserTest.php | 187 +++++++ tests/fixtures/petstore.json | 222 ++++++++ 9 files changed, 2065 insertions(+), 18 deletions(-) create mode 100644 src/Console/Commands/MakeSwaggerMcpToolCommand.php create mode 100644 src/Services/SwaggerParser/SwaggerParser.php create mode 100644 src/Services/SwaggerParser/SwaggerToMcpConverter.php create mode 100644 tests/Unit/NamingConversionTest.php create mode 100644 tests/Unit/SwaggerParserTest.php create mode 100644 tests/fixtures/petstore.json diff --git a/README.md b/README.md index 1540322..7fa317e 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,31 @@ ## ⚠️ Version Information & Breaking Changes -### v1.3.0 Changes (Current) +### v1.4.0 Changes (Latest) 🚀 + +Version 1.4.0 introduces powerful automatic tool generation from Swagger/OpenAPI specifications: + +**New Features:** +- **Swagger/OpenAPI Tool Generator**: Automatically generate MCP tools from any Swagger/OpenAPI specification + - Supports both OpenAPI 3.x and Swagger 2.0 formats + - 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 +334,87 @@ 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 Tool 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 | | ++-----------------+-------------------------+ + +Would you like to modify the base URL? Current: https://api.op.gg (yes/no) [no]: +> no + +📋 Select endpoints to generate tools for: +Include tag: Riot (6 endpoints)? (yes/no) [yes]: +> yes + +Selected 6 endpoints. +🛠️ Generating MCP tools... +Note: operationId '5784a7dfd226e1621b0e6ee8c4f39407' looks like a hash, will use path-based naming +Generating: GetLolRegionRankingsGameTypeTool + ✅ Generated: GetLolRegionRankingsGameTypeTool +Generating: GetLolRegionServerStatsTool + ✅ Generated: GetLolRegionServerStatsTool +... + +📦 Generated 6 MCP tools: + - GetLolRegionRankingsGameTypeTool + - GetLolRegionServerStatsTool + - GetLolMetaChampionsTool + ... + +✅ MCP tools generated successfully! +``` + +**Key Features:** +- **Automatic API parsing**: Supports OpenAPI 3.x and Swagger 2.0 specifications +- **Smart naming**: Converts paths like `/lol/{region}/server-stats` to `GetLolRegionServerStatsTool` +- **Hash detection**: Automatically detects MD5-like operationIds and uses path-based naming instead +- **Interactive mode**: Select which endpoints to convert into tools +- **API testing**: Test API connectivity before generating tools +- **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 tool 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/MakeMcpToolCommand.php b/src/Console/Commands/MakeMcpToolCommand.php index c44f2d1..ce87fb9 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,115 @@ protected function buildClass(string $className) return $this->replaceStubPlaceholders($stub, $className, $toolName); } + /** + * Build a class with dynamic parameters + */ + protected function buildDynamicClass(string $className): string + { + $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'] ?? ''; + $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); + + // Generate the class code + $code = <<', $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..e9a587b --- /dev/null +++ b/src/Console/Commands/MakeSwaggerMcpToolCommand.php @@ -0,0 +1,403 @@ +info('🚀 Swagger/OpenAPI to MCP Tool 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 + $this->selectEndpoints(); + + // Step 4: Configure authentication + if (! $this->option('no-interaction')) { + $this->configureAuthentication(); + } + + // Step 5: Generate tools + $this->generateTools(); + + $this->info('✅ MCP tools generated successfully!'); + + return 0; + + } catch (\Exception $e) { + $this->error('❌ Error: '.$e->getMessage()); + + return 1; + } + } + + /** + * 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 to generate tools for + */ + protected function selectEndpoints(): void + { + $endpoints = $this->parser->getEndpoints(); + + if ($this->option('no-interaction')) { + // In non-interactive mode, select all non-deprecated endpoints + $this->selectedEndpoints = array_filter($endpoints, fn ($e) => ! $e['deprecated']); + $this->info('Selected '.count($this->selectedEndpoints).' endpoints (excluding deprecated).'); + + return; + } + + $this->info('📋 Select endpoints to generate tools for:'); + + $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 MCP tools + */ + protected function generateTools(): void + { + $this->info('🛠️ Generating MCP tools...'); + + $prefix = $this->option('prefix'); + $generated = []; + + foreach ($this->selectedEndpoints as $endpoint) { + // Debug: 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"); + // Clear the operationId to force path-based naming + $endpoint['operationId'] = null; + } + + $className = $this->converter->generateClassName($endpoint, $prefix); + + // Check if class already exists + $path = app_path("MCP/Tools/{$className}.php"); + if (file_exists($path)) { + $this->warn("Skipping {$className} - already exists"); + + continue; + } + + $this->line("Generating: {$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); + + // Create input for the command + $input = new \Symfony\Component\Console\Input\ArrayInput([ + 'name' => $className, + '--programmatic' => true, + ]); + + $output = new \Symfony\Component\Console\Output\NullOutput; + + try { + $makeTool->run($input, $output); + $generated[] = $className; + $this->info(" ✅ Generated: {$className}"); + } catch (\Exception $e) { + $this->error(" ❌ Failed to generate {$className}: ".$e->getMessage()); + } + } + + if (! empty($generated)) { + $this->newLine(); + $this->info('📦 Generated '.count($generated).' MCP tools:'); + foreach ($generated as $className) { + $this->line(" - {$className}"); + } + + $this->newLine(); + $this->info('Next steps:'); + $this->line('1. Review the generated tools in app/MCP/Tools/'); + $this->line('2. Update authentication configuration if needed'); + $this->line('3. Test tools with: php artisan mcp:test-tool '); + $this->line('4. Tools have been automatically registered in config/mcp-server.php'); + } else { + $this->warn('No tools were generated.'); + } + } +} 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..640f59b --- /dev/null +++ b/src/Services/SwaggerParser/SwaggerParser.php @@ -0,0 +1,381 @@ +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..b79da2d --- /dev/null +++ b/src/Services/SwaggerParser/SwaggerToMcpConverter.php @@ -0,0 +1,492 @@ +parser = $parser; + } + + /** + * Set authentication configuration + */ + public function setAuthConfig(array $config): self + { + $this->authConfig = $config; + + 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( + message: 'Validation failed: ' . $validator->errors()->first(), + code: JsonRpcErrorCode::INVALID_REQUEST + ); + } + +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( + message: 'API request failed: ' . $response->body(), + code: 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 = " // Build request\n"; + $code .= " \$request = Http::withHeaders(\$headers ?? [])\n"; + $code .= " ->timeout(30)\n"; + $code .= " ->retry(3, 100);\n\n"; + + // Add query parameters + $queryParams = array_filter($endpoint['parameters'], fn ($p) => $p['in'] === 'query'); + if (! empty($queryParams)) { + $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"; + } + + // Make request + $code .= " // Execute request\n"; + + switch ($method) { + case 'get': + $code .= " \$response = \$request->get(\$url);\n"; + break; + case 'post': + $code .= " \$response = \$request->post(\$url, \$arguments['body'] ?? []);\n"; + break; + case 'put': + $code .= " \$response = \$request->put(\$url, \$arguments['body'] ?? []);\n"; + break; + case 'patch': + $code .= " \$response = \$request->patch(\$url, \$arguments['body'] ?? []);\n"; + break; + case 'delete': + $code .= " \$response = \$request->delete(\$url);\n"; + break; + default: + $code .= " \$response = \$request->{$method}(\$url);\n"; + } + + return $code; + } + + /** + * 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); + } +} diff --git a/tests/Unit/NamingConversionTest.php b/tests/Unit/NamingConversionTest.php new file mode 100644 index 0000000..53a74a2 --- /dev/null +++ b/tests/Unit/NamingConversionTest.php @@ -0,0 +1,119 @@ +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'); +}); \ No newline at end of file diff --git a/tests/Unit/SwaggerParserTest.php b/tests/Unit/SwaggerParserTest.php new file mode 100644 index 0000000..66d27cb --- /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 From 804c10d16277951c2f2bc134414b7352eb56c3cd Mon Sep 17 00:00:00 2001 From: Sangrak Choi Date: Sat, 9 Aug 2025 02:06:14 +0900 Subject: [PATCH 4/7] feat(swagger): add resource generation support to Swagger/OpenAPI generator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the Swagger/OpenAPI generator to support creating both MCP Tools and Resources: - Add resource generation mode with smart type selection (GET → Resource, others → Tool) - Implement programmatic stub system for dynamic class generation - Add comprehensive resource generation tests - Update documentation to reflect dual generation capability - Enhance command UI with type selection workflow Allows users to generate read-only Resources for data endpoints alongside action-oriented Tools. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- README.md | 54 ++- .../Commands/MakeMcpResourceCommand.php | 293 ++++++++++++- src/Console/Commands/MakeMcpToolCommand.php | 71 +--- .../Commands/MakeSwaggerMcpToolCommand.php | 399 +++++++++++++++--- .../SwaggerParser/SwaggerToMcpConverter.php | 232 ++++++++++ src/stubs/resource.programmatic.stub | 46 ++ src/stubs/tool.programmatic.stub | 80 ++++ tests/Unit/ResourceGenerationTest.php | 118 ++++++ 8 files changed, 1153 insertions(+), 140 deletions(-) create mode 100644 src/stubs/resource.programmatic.stub create mode 100644 src/stubs/tool.programmatic.stub create mode 100644 tests/Unit/ResourceGenerationTest.php diff --git a/README.md b/README.md index 7fa317e..f650a68 100644 --- a/README.md +++ b/README.md @@ -34,11 +34,12 @@ ### v1.4.0 Changes (Latest) 🚀 -Version 1.4.0 introduces powerful automatic tool generation from Swagger/OpenAPI specifications: +Version 1.4.0 introduces powerful automatic tool and resource generation from Swagger/OpenAPI specifications: **New Features:** -- **Swagger/OpenAPI Tool Generator**: Automatically generate MCP tools from any Swagger/OpenAPI specification +- **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) @@ -357,7 +358,7 @@ php artisan make:swagger-mcp-tool https://api.example.com/swagger.json \ ```bash ➜ php artisan make:swagger-mcp-tool https://api.op.gg/lol/swagger.json -🚀 Swagger/OpenAPI to MCP Tool Generator +🚀 Swagger/OpenAPI to MCP Generator ========================================= 📄 Loading spec from: https://api.op.gg/lol/swagger.json ✅ Spec loaded successfully! @@ -372,40 +373,57 @@ php artisan make:swagger-mcp-tool https://api.example.com/swagger.json \ | 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 tools for: +📋 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 tools... +🛠️ Generating MCP resources... Note: operationId '5784a7dfd226e1621b0e6ee8c4f39407' looks like a hash, will use path-based naming -Generating: GetLolRegionRankingsGameTypeTool - ✅ Generated: GetLolRegionRankingsGameTypeTool -Generating: GetLolRegionServerStatsTool - ✅ Generated: GetLolRegionServerStatsTool +Generating: LolRegionRankingsGameTypeResource + ✅ Generated: LolRegionRankingsGameTypeResource +Generating: LolRegionServerStatsResource + ✅ Generated: LolRegionServerStatsResource ... -📦 Generated 6 MCP tools: - - GetLolRegionRankingsGameTypeTool - - GetLolRegionServerStatsTool - - GetLolMetaChampionsTool +📦 Generated 6 MCP resources: + - LolRegionRankingsGameTypeResource + - LolRegionServerStatsResource + - LolMetaChampionsResource ... -✅ MCP tools generated successfully! +✅ MCP resources generated successfully! ``` **Key Features:** - **Automatic API parsing**: Supports OpenAPI 3.x and Swagger 2.0 specifications -- **Smart naming**: Converts paths like `/lol/{region}/server-stats` to `GetLolRegionServerStatsTool` +- **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 into tools -- **API testing**: Test API connectivity before generating tools +- **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 tool classes with Laravel HTTP client integration +- **Code generation**: Creates ready-to-use classes with Laravel HTTP client integration The generated tools include: - Proper input validation based on API parameters diff --git a/src/Console/Commands/MakeMcpResourceCommand.php b/src/Console/Commands/MakeMcpResourceCommand.php index 487a143..15a5f86 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,200 @@ 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) + { + $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 { - $dir = dirname($path); - if (! $this->files->isDirectory($dir)) { - $this->files->makeDirectory($dir, 0755, true, true); + $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; } } -} +} \ No newline at end of file diff --git a/src/Console/Commands/MakeMcpToolCommand.php b/src/Console/Commands/MakeMcpToolCommand.php index ce87fb9..0807fb4 100644 --- a/src/Console/Commands/MakeMcpToolCommand.php +++ b/src/Console/Commands/MakeMcpToolCommand.php @@ -200,6 +200,9 @@ protected function buildClass(string $className) */ protected function buildDynamicClass(string $className): string { + // Load the programmatic stub + $stub = $this->files->get(__DIR__.'/../../stubs/tool.programmatic.stub'); + $params = $this->dynamicParams; // Extract parameters @@ -207,7 +210,7 @@ protected function buildDynamicClass(string $className): string $description = $params['description'] ?? 'Auto-generated MCP tool'; $inputSchema = $params['inputSchema'] ?? []; $annotations = $params['annotations'] ?? []; - $executeLogic = $params['executeLogic'] ?? ''; + $executeLogic = $params['executeLogic'] ?? ' return ["result" => "success"];'; $imports = $params['imports'] ?? []; // Build imports @@ -222,57 +225,23 @@ protected function buildDynamicClass(string $className): string // Build annotations $annotationsString = $this->arrayToPhpString($annotations, 2); - // Generate the class code - $code = << '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 $code; + return $stub; } /** diff --git a/src/Console/Commands/MakeSwaggerMcpToolCommand.php b/src/Console/Commands/MakeSwaggerMcpToolCommand.php index e9a587b..582865f 100644 --- a/src/Console/Commands/MakeSwaggerMcpToolCommand.php +++ b/src/Console/Commands/MakeSwaggerMcpToolCommand.php @@ -34,6 +34,11 @@ class MakeSwaggerMcpToolCommand extends Command protected array $selectedEndpoints = []; + /** + * Selected endpoints with their generation type + */ + protected array $selectedEndpointsWithType = []; + /** * Execute the console command. * @@ -41,7 +46,7 @@ class MakeSwaggerMcpToolCommand extends Command */ public function handle() { - $this->info('🚀 Swagger/OpenAPI to MCP Tool Generator'); + $this->info('🚀 Swagger/OpenAPI to MCP Generator'); $this->line('========================================='); try { @@ -53,18 +58,16 @@ public function handle() $this->testApiConnection(); } - // Step 3: Select endpoints - $this->selectEndpoints(); + // Step 3: Select endpoints and their types + $this->selectEndpointsWithTypes(); // Step 4: Configure authentication if (! $this->option('no-interaction')) { $this->configureAuthentication(); } - // Step 5: Generate tools - $this->generateTools(); - - $this->info('✅ MCP tools generated successfully!'); + // Step 5: Generate tools and resources + $this->generateComponents(); return 0; @@ -75,6 +78,56 @@ public function handle() } } + /** + * 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 */ @@ -173,21 +226,186 @@ protected function testApiConnection(): void } /** - * Select endpoints to generate tools for + * 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 all non-deprecated endpoints - $this->selectedEndpoints = array_filter($endpoints, fn ($e) => ! $e['deprecated']); - $this->info('Selected '.count($this->selectedEndpoints).' endpoints (excluding deprecated).'); - + // 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; } - $this->info('📋 Select endpoints to generate tools for:'); + $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'); @@ -329,75 +547,142 @@ protected function configureAuthentication(): void } /** - * Generate MCP tools + * Generate both tools and resources based on selected endpoints */ - protected function generateTools(): void + protected function generateComponents(): void { - $this->info('🛠️ Generating MCP tools...'); + $this->info('🛠️ Generating MCP components...'); $prefix = $this->option('prefix'); - $generated = []; + $generatedTools = []; + $generatedResources = []; - foreach ($this->selectedEndpoints as $endpoint) { - // Debug: Check if operationId looks like a hash + 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"); - // Clear the operationId to force path-based naming $endpoint['operationId'] = null; } - - $className = $this->converter->generateClassName($endpoint, $prefix); - // Check if class already exists - $path = app_path("MCP/Tools/{$className}.php"); - if (file_exists($path)) { - $this->warn("Skipping {$className} - already exists"); + if ($type === 'tool') { + // Generate tool + $className = $this->converter->generateClassName($endpoint, $prefix); + $path = app_path("MCP/Tools/{$className}.php"); - continue; - } + if (file_exists($path)) { + $this->warn("Skipping {$className} - already exists"); + continue; + } - $this->line("Generating: {$className}"); + $this->line("Generating Tool: {$className}"); - // Get tool parameters - $toolParams = $this->converter->convertEndpointToTool($endpoint, $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); + // Create the tool using MakeMcpToolCommand + $makeTool = new MakeMcpToolCommand(app('files')); + $makeTool->setLaravel($this->getLaravel()); + $makeTool->setDynamicParams($toolParams); - // Create input for the command - $input = new \Symfony\Component\Console\Input\ArrayInput([ - 'name' => $className, - '--programmatic' => true, - ]); + $input = new \Symfony\Component\Console\Input\ArrayInput([ + 'name' => $className, + '--programmatic' => true, + ]); - $output = new \Symfony\Component\Console\Output\NullOutput; + $output = new \Symfony\Component\Console\Output\NullOutput; - try { - $makeTool->run($input, $output); - $generated[] = $className; - $this->info(" ✅ Generated: {$className}"); - } catch (\Exception $e) { - $this->error(" ❌ Failed to generate {$className}: ".$e->getMessage()); + 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()); + } } } - if (! empty($generated)) { - $this->newLine(); - $this->info('📦 Generated '.count($generated).' MCP tools:'); - foreach ($generated as $className) { + // 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:'); - $this->line('1. Review the generated tools in app/MCP/Tools/'); - $this->line('2. Update authentication configuration if needed'); - $this->line('3. Test tools with: php artisan mcp:test-tool '); - $this->line('4. Tools have been automatically registered in config/mcp-server.php'); - } else { - $this->warn('No tools were generated.'); + + $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/Services/SwaggerParser/SwaggerToMcpConverter.php b/src/Services/SwaggerParser/SwaggerToMcpConverter.php index b79da2d..84bd955 100644 --- a/src/Services/SwaggerParser/SwaggerToMcpConverter.php +++ b/src/Services/SwaggerParser/SwaggerToMcpConverter.php @@ -489,4 +489,236 @@ protected function convertPathToStudly(string $path): string // 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(30) + ->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); + + // 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('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')"); +}); \ No newline at end of file From f13f548f69ae838d032dd3a35ccdab36290b90cf Mon Sep 17 00:00:00 2001 From: kargnas <1438533+kargnas@users.noreply.github.com> Date: Fri, 8 Aug 2025 17:06:46 +0000 Subject: [PATCH 5/7] Fix styling --- .../Commands/MakeMcpResourceCommand.php | 15 +-- src/Console/Commands/MakeMcpToolCommand.php | 2 +- .../Commands/MakeSwaggerMcpToolCommand.php | 40 +++---- .../SwaggerParser/SwaggerToMcpConverter.php | 102 +++++++++--------- tests/Unit/NamingConversionTest.php | 38 +++---- tests/Unit/ResourceGenerationTest.php | 26 ++--- tests/Unit/SwaggerParserTest.php | 8 +- 7 files changed, 119 insertions(+), 112 deletions(-) diff --git a/src/Console/Commands/MakeMcpResourceCommand.php b/src/Console/Commands/MakeMcpResourceCommand.php index 15a5f86..5cd707d 100644 --- a/src/Console/Commands/MakeMcpResourceCommand.php +++ b/src/Console/Commands/MakeMcpResourceCommand.php @@ -181,7 +181,7 @@ 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}"; @@ -281,22 +281,25 @@ protected function registerResourceInConfig(string $resourceClassName): bool $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.'); @@ -305,7 +308,7 @@ protected function registerResourceInConfig(string $resourceClassName): bool } // Handle empty array case - $fullEntry = trim($resourcesArrayContent) === '' + $fullEntry = trim($resourcesArrayContent) === '' ? "\n {$resourceClassName}::class,\n " : "\n {$resourceClassName}::class,"; @@ -325,4 +328,4 @@ protected function registerResourceInConfig(string $resourceClassName): bool return false; } } -} \ No newline at end of file +} diff --git a/src/Console/Commands/MakeMcpToolCommand.php b/src/Console/Commands/MakeMcpToolCommand.php index 0807fb4..c868633 100644 --- a/src/Console/Commands/MakeMcpToolCommand.php +++ b/src/Console/Commands/MakeMcpToolCommand.php @@ -202,7 +202,7 @@ protected function buildDynamicClass(string $className): string { // Load the programmatic stub $stub = $this->files->get(__DIR__.'/../../stubs/tool.programmatic.stub'); - + $params = $this->dynamicParams; // Extract parameters diff --git a/src/Console/Commands/MakeSwaggerMcpToolCommand.php b/src/Console/Commands/MakeSwaggerMcpToolCommand.php index 582865f..bae150a 100644 --- a/src/Console/Commands/MakeSwaggerMcpToolCommand.php +++ b/src/Console/Commands/MakeSwaggerMcpToolCommand.php @@ -100,10 +100,11 @@ protected function selectEndpointsWithTypes(): void ]; } - $toolCount = count(array_filter($this->selectedEndpointsWithType, fn($e) => $e['type'] === 'tool')); - $resourceCount = count(array_filter($this->selectedEndpointsWithType, fn($e) => $e['type'] === 'resource')); + $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; } @@ -122,8 +123,8 @@ protected function selectEndpointsWithTypes(): void $this->selectIndividuallyWithTypes(); } - $toolCount = count(array_filter($this->selectedEndpointsWithType, fn($e) => $e['type'] === 'tool')); - $resourceCount = count(array_filter($this->selectedEndpointsWithType, fn($e) => $e['type'] === 'resource')); + $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."); } @@ -243,17 +244,17 @@ protected function selectIndividuallyWithTypes(): void // Ask for type $defaultType = $endpoint['method'] === 'GET' ? 'Resource' : 'Tool'; $typeChoice = $this->choice( - "Generate as", + 'Generate as', ['Tool (for actions)', 'Resource (for read-only data)', 'Skip'], $endpoint['method'] === 'GET' ? 1 : 0 ); - if (!str_contains($typeChoice, 'Skip')) { + 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."); + $this->warn('Only GET endpoints can be resources. Generating as Tool instead.'); $type = 'tool'; } @@ -285,7 +286,7 @@ protected function selectByTagWithTypes(): void if ($this->confirm("Include tag: {$label}?", true)) { foreach ($endpoints as $endpoint) { - if ($endpoint['deprecated'] && !$this->confirm("Include deprecated: {$endpoint['method']} {$endpoint['path']}?", false)) { + if ($endpoint['deprecated'] && ! $this->confirm("Include deprecated: {$endpoint['method']} {$endpoint['path']}?", false)) { continue; } @@ -299,7 +300,7 @@ protected function selectByTagWithTypes(): void $this->info($endpointLabel); $typeChoice = $this->choice( - "Generate as", + 'Generate as', ['Tool', 'Resource', 'Skip'], 0 ); @@ -309,7 +310,7 @@ protected function selectByTagWithTypes(): void // Validate: only GET can be resources if ($type === 'resource' && $endpoint['method'] !== 'GET') { - $this->warn("Only GET endpoints can be resources. Generating as Tool instead."); + $this->warn('Only GET endpoints can be resources. Generating as Tool instead.'); $type = 'tool'; } @@ -344,7 +345,7 @@ protected function selectByPathWithTypes(): void 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)) { + if ($endpoint['deprecated'] && ! $this->confirm("Include deprecated: {$endpoint['method']} {$endpoint['path']}?", false)) { continue; } @@ -355,7 +356,7 @@ protected function selectByPathWithTypes(): void $this->info($endpointLabel); $typeChoice = $this->choice( - "Generate as", + 'Generate as', ['Tool', 'Resource', 'Skip'], $endpoint['method'] === 'GET' ? 1 : 0 ); @@ -365,7 +366,7 @@ protected function selectByPathWithTypes(): void // Validate: only GET can be resources if ($type === 'resource' && $endpoint['method'] !== 'GET') { - $this->warn("Only GET endpoints can be resources. Generating as Tool instead."); + $this->warn('Only GET endpoints can be resources. Generating as Tool instead.'); $type = 'tool'; } @@ -397,6 +398,7 @@ protected function selectEndpoints(): void $this->selectedEndpoints = array_filter($endpoints, fn ($e) => ! $e['deprecated']); $this->info('Selected '.count($this->selectedEndpoints).' endpoints (excluding deprecated).'); } + return; } @@ -562,7 +564,7 @@ protected function generateComponents(): void $type = $item['type']; // Check if operationId looks like a hash - if (!empty($endpoint['operationId']) && preg_match('/^[a-f0-9]{32}$/i', $endpoint['operationId'])) { + 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; } @@ -574,6 +576,7 @@ protected function generateComponents(): void if (file_exists($path)) { $this->warn("Skipping {$className} - already exists"); + continue; } @@ -609,6 +612,7 @@ protected function generateComponents(): void if (file_exists($path)) { $this->warn("Skipping {$className} - already exists"); + continue; } @@ -663,17 +667,17 @@ protected function generateComponents(): void $this->info('✅ MCP components generated successfully!'); $this->newLine(); $this->info('Next steps:'); - + $stepNumber = 1; - - if (!empty($generatedTools)) { + + 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)) { + 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"); diff --git a/src/Services/SwaggerParser/SwaggerToMcpConverter.php b/src/Services/SwaggerParser/SwaggerToMcpConverter.php index 84bd955..7905dc0 100644 --- a/src/Services/SwaggerParser/SwaggerToMcpConverter.php +++ b/src/Services/SwaggerParser/SwaggerToMcpConverter.php @@ -54,9 +54,9 @@ public function convertEndpointToTool(array $endpoint, string $className): array protected function generateToolName(array $endpoint): string { // Check if operationId is a hash (32 char hex string) - $useOperationId = ! empty($endpoint['operationId']) + $useOperationId = ! empty($endpoint['operationId']) && ! preg_match('/^[a-f0-9]{32}$/i', $endpoint['operationId']); - + if ($useOperationId) { return Str::kebab($endpoint['operationId']); } @@ -67,7 +67,7 @@ protected function generateToolName(array $endpoint): string return "{$method}-{$path}"; } - + /** * Convert API path to kebab-case name * Example: /lol/{region}/server-stats -> lol-region-server-stats @@ -76,19 +76,19 @@ 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; } @@ -439,9 +439,9 @@ protected function generateImports(array $endpoint): array public function generateClassName(array $endpoint, ?string $prefix = null): string { // Check if operationId is a hash (32 char hex string) - $useOperationId = ! empty($endpoint['operationId']) + $useOperationId = ! empty($endpoint['operationId']) && ! preg_match('/^[a-f0-9]{32}$/i', $endpoint['operationId']); - + if ($useOperationId) { $name = Str::studly($endpoint['operationId']); } else { @@ -462,7 +462,7 @@ public function generateClassName(array $endpoint, ?string $prefix = null): stri return $name; } - + /** * Convert API path to StudlyCase name * Example: /lol/{region}/server-stats -> LolRegionServerStats @@ -471,21 +471,21 @@ 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); } @@ -496,9 +496,9 @@ protected function convertPathToStudly(string $path): string public function generateResourceClassName(array $endpoint, ?string $prefix = null): string { // Check if operationId is a hash (32 char hex string) - $useOperationId = ! empty($endpoint['operationId']) + $useOperationId = ! empty($endpoint['operationId']) && ! preg_match('/^[a-f0-9]{32}$/i', $endpoint['operationId']); - + if ($useOperationId) { $name = Str::studly($endpoint['operationId']); } else { @@ -547,10 +547,10 @@ 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}"; } @@ -559,16 +559,16 @@ protected function generateResourceUri(array $endpoint): string */ protected function generateResourceName(array $endpoint): string { - if (!empty($endpoint['summary'])) { + 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'; + + return ucfirst(end($parts)).' Data'; } /** @@ -577,19 +577,19 @@ protected function generateResourceName(array $endpoint): string protected function generateResourceDescription(array $endpoint): string { $description = $endpoint['description'] ?: $endpoint['summary']; - - if (!$description) { + + 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); + if (! empty($endpoint['parameters'])) { + $params = array_map(fn ($p) => $p['name'], $endpoint['parameters']); + $description .= ' Parameters: '.implode(', ', $params); } - + return addslashes($description); } @@ -640,19 +640,19 @@ protected function generateResourceReadLogic(array $endpoint): string // Replace placeholders $logic = str_replace('{{ baseUrl }}', $baseUrl, $logic); $logic = str_replace('{{ path }}', $path, $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; } @@ -661,19 +661,19 @@ protected function generateResourceReadLogic(array $endpoint): string */ protected function generateResourcePathParams(array $parameters): string { - $pathParams = array_filter($parameters, fn($p) => $p['in'] === 'path'); - + $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"; + $code .= " // \$url = str_replace('{{$name}}', \$valueFor".Str::studly($name).", \$url);\n"; } - + return rtrim($code); } @@ -685,18 +685,18 @@ protected function generateResourceAuthHeaders(array $endpoint): string if (empty($endpoint['security']) && empty($this->authConfig)) { return ''; } - + $code = ''; - - if (!empty($this->authConfig['bearer_token'])) { + + if (! empty($this->authConfig['bearer_token'])) { $code .= " \$headers['Authorization'] = 'Bearer ' . config('services.api.token');\n"; } - - if (!empty($this->authConfig['api_key'])) { + + 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); } @@ -705,20 +705,20 @@ protected function generateResourceAuthHeaders(array $endpoint): string */ protected function generateResourceQueryParams(array $parameters): string { - $queryParams = array_filter($parameters, fn($p) => $p['in'] === 'query'); - + $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 .= " // '{$name}' => \$valueFor".Str::studly($name).", // {$required}\n"; } $code .= " ])\n"; - + return $code; } } diff --git a/tests/Unit/NamingConversionTest.php b/tests/Unit/NamingConversionTest.php index 53a74a2..4adb924 100644 --- a/tests/Unit/NamingConversionTest.php +++ b/tests/Unit/NamingConversionTest.php @@ -14,7 +14,7 @@ 'method' => $method, 'operationId' => $operationId, ]; - + $className = $this->converter->generateClassName($endpoint); expect($className)->toBe($expected); })->with([ @@ -22,35 +22,35 @@ ['/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'], @@ -70,17 +70,17 @@ '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'], @@ -93,27 +93,27 @@ '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'); -}); \ No newline at end of file +}); diff --git a/tests/Unit/ResourceGenerationTest.php b/tests/Unit/ResourceGenerationTest.php index bfcffde..9901dca 100644 --- a/tests/Unit/ResourceGenerationTest.php +++ b/tests/Unit/ResourceGenerationTest.php @@ -14,7 +14,7 @@ 'method' => 'GET', 'operationId' => $operationId, ]; - + $className = $this->converter->generateResourceClassName($endpoint); expect($className)->toBe($expected); })->with([ @@ -22,11 +22,11 @@ ['/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'], ]); @@ -34,7 +34,7 @@ test('converts endpoint to resource with correct URI', function () { $parser = new SwaggerParser; $converter = new SwaggerToMcpConverter($parser); - + $endpoint = [ 'path' => '/api/users/{id}', 'method' => 'GET', @@ -50,9 +50,9 @@ '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}'); @@ -63,7 +63,7 @@ test('generates resource URI correctly', function ($path, $expectedUri) { $parser = new SwaggerParser; $converter = new SwaggerToMcpConverter($parser); - + $endpoint = [ 'path' => $path, 'method' => 'GET', @@ -78,7 +78,7 @@ 'security' => [], ]; $resourceParams = $converter->convertEndpointToResource($endpoint, 'TestResource'); - + expect($resourceParams['uri'])->toBe($expectedUri); })->with([ ['/api/users', 'api://users'], @@ -90,13 +90,13 @@ 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', @@ -110,9 +110,9 @@ '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')"); -}); \ No newline at end of file +}); diff --git a/tests/Unit/SwaggerParserTest.php b/tests/Unit/SwaggerParserTest.php index 66d27cb..a76a304 100644 --- a/tests/Unit/SwaggerParserTest.php +++ b/tests/Unit/SwaggerParserTest.php @@ -150,24 +150,24 @@ public function test_can_generate_class_names() $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); } From f0ea9cf6504b73bbb20d35be39b648d432184705 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Fri, 8 Aug 2025 17:16:39 +0000 Subject: [PATCH 6/7] feat: implement security and maintainability improvements - Add URL validation to prevent SSRF attacks in SwaggerParser - Standardize error handling to use JsonRpcErrorException - Make HTTP timeouts configurable instead of hardcoded - Refactor generateHttpRequest() method into smaller focused methods Co-authored-by: Sangrak Choi --- src/Services/SwaggerParser/SwaggerParser.php | 28 +++- .../SwaggerParser/SwaggerToMcpConverter.php | 121 +++++++++++------- 2 files changed, 102 insertions(+), 47 deletions(-) diff --git a/src/Services/SwaggerParser/SwaggerParser.php b/src/Services/SwaggerParser/SwaggerParser.php index 640f59b..eb3ccd6 100644 --- a/src/Services/SwaggerParser/SwaggerParser.php +++ b/src/Services/SwaggerParser/SwaggerParser.php @@ -17,12 +17,38 @@ class SwaggerParser protected array $endpoints = []; + /** + * Validate URL to prevent SSRF attacks + */ + protected function isValidUrl(string $source): bool + { + // Basic URL validation + if (!filter_var($source, FILTER_VALIDATE_URL)) { + return false; + } + + $parsedUrl = parse_url($source); + + // Ensure scheme is HTTP or HTTPS only + if (!in_array($parsedUrl['scheme'] ?? '', ['http', 'https'])) { + return false; + } + + // Additional SSRF protections could be added here: + // - Block private IP ranges (127.0.0.1, 10.0.0.0/8, etc.) + // - Block localhost domains + // - Whitelist allowed domains + // For now, we allow all HTTP/HTTPS URLs but with proper validation + + return true; + } + /** * Load Swagger/OpenAPI spec from URL or file */ public function load(string $source): self { - if (Str::startsWith($source, ['http://', 'https://'])) { + if ($this->isValidUrl($source)) { $this->loadFromUrl($source); } else { $this->loadFromFile($source); diff --git a/src/Services/SwaggerParser/SwaggerToMcpConverter.php b/src/Services/SwaggerParser/SwaggerToMcpConverter.php index 7905dc0..86befd7 100644 --- a/src/Services/SwaggerParser/SwaggerToMcpConverter.php +++ b/src/Services/SwaggerParser/SwaggerToMcpConverter.php @@ -10,6 +10,8 @@ class SwaggerToMcpConverter protected array $authConfig = []; + protected int $httpTimeout = 30; + public function __construct(SwaggerParser $parser) { $this->parser = $parser; @@ -25,6 +27,16 @@ public function setAuthConfig(array $config): self return $this; } + /** + * Set HTTP timeout in seconds + */ + public function setHttpTimeout(int $timeout): self + { + $this->httpTimeout = $timeout; + + return $this; + } + /** * Convert endpoint to MCP tool parameters */ @@ -278,8 +290,8 @@ protected function generateExecuteLogic(array $endpoint): string if ($validator->fails()) { throw new JsonRpcErrorException( - message: 'Validation failed: ' . $validator->errors()->first(), - code: JsonRpcErrorCode::INVALID_REQUEST + 'Validation failed: ' . $validator->errors()->first(), + JsonRpcErrorCode::INVALID_PARAMS ); } @@ -300,8 +312,8 @@ protected function generateExecuteLogic(array $endpoint): string // Check response status if (!$response->successful()) { throw new JsonRpcErrorException( - message: 'API request failed: ' . $response->body(), - code: JsonRpcErrorCode::INTERNAL_ERROR + 'API request failed: ' . $response->body(), + JsonRpcErrorCode::INTERNAL_ERROR ); } @@ -375,53 +387,69 @@ protected function generateAuthLogic(array $endpoint): string */ protected function generateHttpRequest(string $method, array $endpoint): string { - $code = " // Build request\n"; - $code .= " \$request = Http::withHeaders(\$headers ?? [])\n"; - $code .= " ->timeout(30)\n"; - $code .= " ->retry(3, 100);\n\n"; + $code = $this->generateHttpClientSetup(); + $code .= $this->generateQueryParametersCode($endpoint['parameters']); + $code .= $this->generateHttpMethodCall($method); - // Add query parameters - $queryParams = array_filter($endpoint['parameters'], fn ($p) => $p['in'] === 'query'); - if (! empty($queryParams)) { - $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"; - } - - // Make request - $code .= " // Execute request\n"; - - switch ($method) { - case 'get': - $code .= " \$response = \$request->get(\$url);\n"; - break; - case 'post': - $code .= " \$response = \$request->post(\$url, \$arguments['body'] ?? []);\n"; - break; - case 'put': - $code .= " \$response = \$request->put(\$url, \$arguments['body'] ?? []);\n"; - break; - case 'patch': - $code .= " \$response = \$request->patch(\$url, \$arguments['body'] ?? []);\n"; - break; - case 'delete': - $code .= " \$response = \$request->delete(\$url);\n"; - break; - default: - $code .= " \$response = \$request->{$method}(\$url);\n"; + 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 */ @@ -616,7 +644,7 @@ protected function generateResourceReadLogic(array $endpoint): string // Make HTTP request $response = Http::withHeaders($headers) - ->timeout(30) + ->timeout({{ timeout }}) ->retry(3, 100) {{ queryParams }} ->get($url); @@ -640,6 +668,7 @@ protected function generateResourceReadLogic(array $endpoint): string // 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'] ?? []); From 57558f136aad9d876b079daa7270ea05e2934ccc Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Fri, 8 Aug 2025 17:16:58 +0000 Subject: [PATCH 7/7] Fix styling --- src/Services/SwaggerParser/SwaggerParser.php | 6 +++--- .../SwaggerParser/SwaggerToMcpConverter.php | 14 +++++++------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/Services/SwaggerParser/SwaggerParser.php b/src/Services/SwaggerParser/SwaggerParser.php index eb3ccd6..6b77cbf 100644 --- a/src/Services/SwaggerParser/SwaggerParser.php +++ b/src/Services/SwaggerParser/SwaggerParser.php @@ -23,14 +23,14 @@ class SwaggerParser protected function isValidUrl(string $source): bool { // Basic URL validation - if (!filter_var($source, FILTER_VALIDATE_URL)) { + if (! filter_var($source, FILTER_VALIDATE_URL)) { return false; } $parsedUrl = parse_url($source); - + // Ensure scheme is HTTP or HTTPS only - if (!in_array($parsedUrl['scheme'] ?? '', ['http', 'https'])) { + if (! in_array($parsedUrl['scheme'] ?? '', ['http', 'https'])) { return false; } diff --git a/src/Services/SwaggerParser/SwaggerToMcpConverter.php b/src/Services/SwaggerParser/SwaggerToMcpConverter.php index 86befd7..c501791 100644 --- a/src/Services/SwaggerParser/SwaggerToMcpConverter.php +++ b/src/Services/SwaggerParser/SwaggerToMcpConverter.php @@ -399,9 +399,9 @@ protected function generateHttpRequest(string $method, array $endpoint): string */ protected function generateHttpClientSetup(): string { - return " // Build request\n" . - " \$request = Http::withHeaders(\$headers ?? [])\n" . - " ->timeout({$this->httpTimeout})\n" . + return " // Build request\n". + " \$request = Http::withHeaders(\$headers ?? [])\n". + " ->timeout({$this->httpTimeout})\n". " ->retry(3, 100);\n\n"; } @@ -411,21 +411,21 @@ protected function generateHttpClientSetup(): string 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"; @@ -440,7 +440,7 @@ protected function generateHttpMethodCall(string $method): string { $code = " // Execute request\n"; - return $code . match ($method) { + 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",