diff --git a/composer.json b/composer.json index 495b4174..6c6d6634 100644 --- a/composer.json +++ b/composer.json @@ -14,21 +14,21 @@ }, "require": { "php": "^8.1", - "guzzlehttp/guzzle": "^7.9", - "illuminate/console": "^10.0|^11.0|^12.0", - "illuminate/contracts": "^10.0|^11.0|^12.0", - "illuminate/routing": "^10.0|^11.0|^12.0", - "illuminate/support": "^10.0|^11.0|^12.0", - "laravel/mcp": "^0.1.1", - "laravel/prompts": "^0.1.9|^0.3", - "laravel/roster": "^0.2.5" + "guzzlehttp/guzzle": "^7.10", + "illuminate/console": "^10.49.0|^11.45.3|^12.28.1", + "illuminate/contracts": "^10.49.0|^11.45.3|^12.28.1", + "illuminate/routing": "^10.49.0|^11.45.3|^12.28.1", + "illuminate/support": "^10.49.0|^11.45.3|^12.28.1", + "laravel/mcp": "dev-main", + "laravel/prompts": "0.1.25|^0.3.6", + "laravel/roster": "^0.2.6" }, "require-dev": { - "laravel/pint": "^1.14", - "mockery/mockery": "^1.6", - "orchestra/testbench": "^8.22.0|^9.0|^10.0", - "pestphp/pest": "^2.0|^3.0", - "phpstan/phpstan": "^2.0" + "laravel/pint": "1.20", + "mockery/mockery": "^1.6.12", + "orchestra/testbench": "^8.36.0|^9.15.0|^10.6", + "pestphp/pest": "^2.36.0|^3.8.4", + "phpstan/phpstan": "^2.1.27" }, "autoload": { "psr-4": { diff --git a/src/BoostServiceProvider.php b/src/BoostServiceProvider.php index a0d53219..4bd4e299 100644 --- a/src/BoostServiceProvider.php +++ b/src/BoostServiceProvider.php @@ -14,7 +14,7 @@ use Illuminate\View\Compilers\BladeCompiler; use Laravel\Boost\Mcp\Boost; use Laravel\Boost\Middleware\InjectBoost; -use Laravel\Mcp\Server\Facades\Mcp; +use Laravel\Mcp\Facades\Mcp; use Laravel\Roster\Roster; class BoostServiceProvider extends ServiceProvider diff --git a/src/Console/ExecuteToolCommand.php b/src/Console/ExecuteToolCommand.php index 3d2d312d..28478b5c 100644 --- a/src/Console/ExecuteToolCommand.php +++ b/src/Console/ExecuteToolCommand.php @@ -6,7 +6,10 @@ use Illuminate\Console\Command; use Laravel\Boost\Mcp\ToolRegistry; -use Laravel\Mcp\Server\Tools\ToolResult; +use Laravel\Mcp\Request; +use Laravel\Mcp\Response; +use Laravel\Mcp\Server\Tool; +use Throwable; class ExecuteToolCommand extends Command { @@ -36,22 +39,34 @@ public function handle(): int return 1; } - try { - // Execute the tool - $tool = app($toolClass); - $result = $tool->handle($arguments ?? []); + /** @var Tool $tool */ + $tool = app($toolClass); - // Output the result as JSON for the parent process - echo json_encode($result->toArray()); + $request = new Request($arguments ?? []); - return 0; + try { + /** @var Response $response */ + $response = $tool->handle($request); // @phpstan-ignore-line + } catch (Throwable $e) { + $errorResult = Response::error("Tool execution failed (E_THROWABLE): {$e->getMessage()}"); + + $this->error(json_encode([ + 'isError' => true, + 'content' => [ + $errorResult->content()->toTool($tool), + ], + ])); + + return static::FAILURE; + } - } catch (\Throwable $e) { - // Output error result - $errorResult = ToolResult::error("Tool execution failed (E_THROWABLE): {$e->getMessage()}"); - $this->error(json_encode($errorResult->toArray())); + echo json_encode([ + 'isError' => $response->isError(), + 'content' => [ + $response->content()->toTool($tool), + ], + ]); - return 1; - } + return static::SUCCESS; } } diff --git a/src/Mcp/Boost.php b/src/Mcp/Boost.php index fb0fb5f3..cc756303 100644 --- a/src/Mcp/Boost.php +++ b/src/Mcp/Boost.php @@ -10,43 +10,74 @@ class Boost extends Server { - public string $serverName = 'Laravel Boost'; + /** + * The MCP server's name. + */ + protected string $name = 'Laravel Boost'; - public string $serverVersion = '0.0.1'; + /** + * The MCP server's version. + */ + protected string $version = '0.0.1'; - public string $instructions = 'Laravel ecosystem MCP server offering database schema access, Artisan commands, error logs, Tinker execution, semantic documentation search and more. Boost helps with code generation.'; + /** + * The MCP server's instructions for the LLM. + */ + protected string $instructions = 'Laravel ecosystem MCP server offering database schema access, Artisan commands, error logs, Tinker execution, semantic documentation search and more. Boost helps with code generation.'; + /** + * The default pagination length for resources that support pagination. + */ public int $defaultPaginationLength = 50; /** - * @var string[] + * The tools registered with this MCP server. + * + * @var array> + */ + protected array $tools = []; + + /** + * The resources registered with this MCP server. + * + * @var array> */ - public array $resources = [ + protected array $resources = [ ApplicationInfo::class, ]; + /** + * The prompts registered with this MCP server. + * + * @var array> + */ + protected array $prompts = []; + public function boot(): void { - $this->discoverTools(); - $this->discoverResources(); - $this->discoverPrompts(); + collect($this->discoverTools())->each(fn (string $tool) => $this->tools[] = $tool); + collect($this->discoverResources())->each(fn (string $resource) => $this->resources[] = $resource); + collect($this->discoverPrompts())->each(fn (string $prompt) => $this->prompts[] = $prompt); // Override the tools/call method to use our ToolExecutor $this->methods['tools/call'] = CallToolWithExecutor::class; } /** - * @return array + * @return array> */ protected function discoverTools(): array { + $tools = []; + $excludedTools = config('boost.mcp.tools.exclude', []); $toolDir = new \DirectoryIterator(__DIR__.DIRECTORY_SEPARATOR.'Tools'); + foreach ($toolDir as $toolFile) { if ($toolFile->isFile() && $toolFile->getExtension() === 'php') { $fqdn = 'Laravel\\Boost\\Mcp\\Tools\\'.$toolFile->getBasename('.php'); if (class_exists($fqdn) && ! in_array($fqdn, $excludedTools, true)) { - $this->addTool($fqdn); + $tools[] = $fqdn; } } } @@ -54,25 +85,28 @@ protected function discoverTools(): array $extraTools = config('boost.mcp.tools.include', []); foreach ($extraTools as $toolClass) { if (class_exists($toolClass)) { - $this->addTool($toolClass); + $tools[] = $toolClass; } } - return $this->registeredTools; + return $tools; } /** - * @return array + * @return array> */ protected function discoverResources(): array { + $resources = []; + $excludedResources = config('boost.mcp.resources.exclude', []); $resourceDir = new \DirectoryIterator(__DIR__.DIRECTORY_SEPARATOR.'Resources'); + foreach ($resourceDir as $resourceFile) { if ($resourceFile->isFile() && $resourceFile->getExtension() === 'php') { - $fqdn = 'Laravel\\Boost\\Mcp\\Resources\\'.$resourceFile; - if (class_exists($fqdn) && ! in_array($fqdn, $excludedResources, true)) { - $this->addResource($fqdn); + $fqdn = 'Laravel\\Boost\\Mcp\\Resources\\'.$resourceFile->getBasename('.php'); + if (class_exists($fqdn) && ! in_array($fqdn, $excludedResources, true) && $fqdn !== ApplicationInfo::class) { + $resources[] = $fqdn; } } } @@ -80,25 +114,28 @@ protected function discoverResources(): array $extraResources = config('boost.mcp.resources.include', []); foreach ($extraResources as $resourceClass) { if (class_exists($resourceClass)) { - $this->addResource($resourceClass); + $resources[] = $resourceClass; } } - return $this->registeredResources; + return $resources; } /** - * @return array + * @return array> */ protected function discoverPrompts(): array { + $prompts = []; + $excludedPrompts = config('boost.mcp.prompts.exclude', []); $promptDir = new \DirectoryIterator(__DIR__.DIRECTORY_SEPARATOR.'Prompts'); + foreach ($promptDir as $promptFile) { if ($promptFile->isFile() && $promptFile->getExtension() === 'php') { - $fqdn = 'Laravel\\Boost\\Mcp\\Prompts\\'.$promptFile; + $fqdn = 'Laravel\\Boost\\Mcp\\Prompts\\'.$promptFile->getBasename('.php'); if (class_exists($fqdn) && ! in_array($fqdn, $excludedPrompts, true)) { - $this->addPrompt($fqdn); + $prompts[] = $fqdn; } } } @@ -106,10 +143,10 @@ protected function discoverPrompts(): array $extraPrompts = config('boost.mcp.prompts.include', []); foreach ($extraPrompts as $promptClass) { if (class_exists($promptClass)) { - $this->addResource($promptClass); + $prompts[] = $promptClass; } } - return $this->registeredPrompts; + return $prompts; } } diff --git a/src/Mcp/Methods/CallToolWithExecutor.php b/src/Mcp/Methods/CallToolWithExecutor.php index 1d2baf5b..f4f6545b 100644 --- a/src/Mcp/Methods/CallToolWithExecutor.php +++ b/src/Mcp/Methods/CallToolWithExecutor.php @@ -4,57 +4,63 @@ namespace Laravel\Boost\Mcp\Methods; -use Illuminate\Support\ItemNotFoundException; use Laravel\Boost\Mcp\ToolExecutor; -use Laravel\Mcp\Server\Contracts\Methods\Method; +use Laravel\Mcp\Response; +use Laravel\Mcp\Server\Contracts\Errable; +use Laravel\Mcp\Server\Contracts\Method; +use Laravel\Mcp\Server\Exceptions\JsonRpcException; +use Laravel\Mcp\Server\Methods\Concerns\InteractsWithResponses; use Laravel\Mcp\Server\ServerContext; -use Laravel\Mcp\Server\Tools\ToolResult; use Laravel\Mcp\Server\Transport\JsonRpcRequest; use Laravel\Mcp\Server\Transport\JsonRpcResponse; use Throwable; -class CallToolWithExecutor implements Method +class CallToolWithExecutor implements Method, Errable { + use InteractsWithResponses; + + public function __construct(protected ToolExecutor $executor) + { + // + } + /** * Handle the JSON-RPC tool/call request with process isolation. - * - * @param JsonRpcRequest $request - * @param ServerContext $context - * @return JsonRpcResponse */ public function handle(JsonRpcRequest $request, ServerContext $context): JsonRpcResponse { - try { - $tool = $context->tools()->firstOrFail(fn ($tool) => $tool->name() === $request->params['name']); - } catch (ItemNotFoundException) { - return JsonRpcResponse::create( - $request->id, - ToolResult::error('Tool not found') - ); - } catch (Throwable $e) { - return JsonRpcResponse::create( + if (is_null($request->get('name'))) { + throw new JsonRpcException( + 'Missing [name] parameter.', + -32602, $request->id, - ToolResult::error('Error finding tool: '.$e->getMessage()) ); } - try { - $executor = app(ToolExecutor::class); - - $arguments = []; - if (isset($request->params['arguments']) && is_array($request->params['arguments'])) { - $arguments = $request->params['arguments']; - } + $tool = $context + ->tools($request->toRequest()) + ->first( + fn ($tool): bool => $tool->name() === $request->params['name'], + fn () => throw new JsonRpcException( + "Tool [{$request->params['name']}] not found.", + -32602, + $request->id, + )); - $result = $executor->execute(get_class($tool), $arguments); - - return JsonRpcResponse::create($request->id, $result); + $arguments = []; + if (isset($request->params['arguments']) && is_array($request->params['arguments'])) { + $arguments = $request->params['arguments']; + } + try { + $response = $this->executor->execute(get_class($tool), $arguments); } catch (Throwable $e) { - return JsonRpcResponse::create( - $request->id, - ToolResult::error('Tool execution error: '.$e->getMessage()) - ); + $response = Response::error('Tool execution error: '.$e->getMessage()); } + + return $this->toJsonRpcResponse($request, $response, fn ($responses) => [ + 'content' => $responses->map(fn ($response) => $response->content()->toTool($tool))->all(), + 'isError' => $responses->contains(fn ($response) => $response->isError()), + ]); } } diff --git a/src/Mcp/Resources/ApplicationInfo.php b/src/Mcp/Resources/ApplicationInfo.php index e6562003..92ebb493 100644 --- a/src/Mcp/Resources/ApplicationInfo.php +++ b/src/Mcp/Resources/ApplicationInfo.php @@ -6,6 +6,8 @@ use Laravel\Boost\Mcp\ToolExecutor; use Laravel\Boost\Mcp\Tools\ApplicationInfo as ApplicationInfoTool; +use Laravel\Mcp\Request; +use Laravel\Mcp\Response; use Laravel\Mcp\Server\Resource; class ApplicationInfo extends Resource @@ -14,35 +16,38 @@ public function __construct(protected ToolExecutor $toolExecutor) { } - public function description(): string + /** + * The resource's description. + */ + protected string $description = 'Comprehensive application information including PHP version, Laravel version, database engine, all installed packages with their versions, and all Eloquent models in the application.'; + + /** + * The resource's URI. + */ + protected string $uri = 'file://instructions/application-info.md'; + + /** + * The resource's MIME type. + */ + protected string $mimeType = 'text/markdown'; + + /** + * Handle the resource request. + */ + public function handle(): Response { - return 'Comprehensive application information including PHP version, Laravel version, database engine, all installed packages with their versions, and all Eloquent models in the application.'; - } - - public function uri(): string - { - return 'file://instructions/application-info.md'; - } - - public function mimeType(): string - { - return 'text/markdown'; - } - - public function read(): string - { - $result = $this->toolExecutor->execute(ApplicationInfoTool::class); + $response = $this->toolExecutor->execute(ApplicationInfoTool::class); - if ($result->isError) { - return 'Error fetching application information: '.$result->toArray()['content'][0]['text']; + if ($response->isError()) { + return $response; // Return the error response directly } - $data = json_decode($result->toArray()['content'][0]['text'], true); + $data = json_decode((string) $response->content(), true); if (! $data) { - return 'Error parsing application information'; + return Response::error('Error parsing application information'); } - return json_encode($data, JSON_PRETTY_PRINT); + return Response::json($data); } } diff --git a/src/Mcp/ToolExecutor.php b/src/Mcp/ToolExecutor.php index 104f0de0..f68df218 100644 --- a/src/Mcp/ToolExecutor.php +++ b/src/Mcp/ToolExecutor.php @@ -6,7 +6,7 @@ use Dotenv\Dotenv; use Illuminate\Support\Env; -use Laravel\Mcp\Server\Tools\ToolResult; +use Laravel\Mcp\Response; use Symfony\Component\Process\Exception\ProcessFailedException; use Symfony\Component\Process\Exception\ProcessTimedOutException; use Symfony\Component\Process\Process; @@ -17,16 +17,16 @@ public function __construct() { } - public function execute(string $toolClass, array $arguments = []): ToolResult + public function execute(string $toolClass, array $arguments = []): Response { if (! ToolRegistry::isToolAllowed($toolClass)) { - return ToolResult::error("Tool not registered or not allowed: {$toolClass}"); + return Response::error("Tool not registered or not allowed: {$toolClass}"); } return $this->executeInSubprocess($toolClass, $arguments); } - protected function executeInSubprocess(string $toolClass, array $arguments): ToolResult + protected function executeInSubprocess(string $toolClass, array $arguments): Response { $command = $this->buildCommand($toolClass, $arguments); @@ -36,6 +36,7 @@ protected function executeInSubprocess(string $toolClass, array $arguments): Too app()->environmentPath(), app()->environmentFile() ))->safeLoad(); + $cleanEnv = array_fill_keys(array_keys($env), false); $process = new Process( @@ -51,19 +52,19 @@ protected function executeInSubprocess(string $toolClass, array $arguments): Too $decoded = json_decode($output, true); if (json_last_error() !== JSON_ERROR_NONE) { - return ToolResult::error('Invalid JSON output from tool process: '.json_last_error_msg()); + return Response::error('Invalid JSON output from tool process: '.json_last_error_msg()); } - return $this->reconstructToolResult($decoded); + return $this->reconstructResponse($decoded); } catch (ProcessTimedOutException $e) { $process->stop(); - return ToolResult::error("Tool execution timed out after {$this->getTimeout($arguments)} seconds"); + return Response::error("Tool execution timed out after {$this->getTimeout($arguments)} seconds"); } catch (ProcessFailedException $e) { $errorOutput = $process->getErrorOutput().$process->getOutput(); - return ToolResult::error("Process tool execution failed: {$errorOutput}"); + return Response::error("Process tool execution failed: {$errorOutput}"); } } @@ -75,19 +76,19 @@ protected function getTimeout(array $arguments): int } /** - * Reconstruct a ToolResult from JSON data. + * Reconstruct a Response from JSON data. * * @param array $data */ - protected function reconstructToolResult(array $data): ToolResult + protected function reconstructResponse(array $data): Response { if (! isset($data['isError']) || ! isset($data['content'])) { - return ToolResult::error('Invalid tool result format'); + return Response::error('Invalid tool response format.'); } if ($data['isError']) { - // Extract the actual text content from the content array $errorText = 'Unknown error'; + if (is_array($data['content']) && ! empty($data['content'])) { $firstContent = $data['content'][0] ?? []; if (is_array($firstContent)) { @@ -95,27 +96,26 @@ protected function reconstructToolResult(array $data): ToolResult } } - return ToolResult::error($errorText); + return Response::error($errorText); } - // Handle successful responses - extract text content + // Handle array format - extract text content if (is_array($data['content']) && ! empty($data['content'])) { $firstContent = $data['content'][0] ?? []; if (is_array($firstContent)) { $text = $firstContent['text'] ?? ''; - // Try to detect if it's JSON $decoded = json_decode($text, true); if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) { - return ToolResult::json($decoded); + return Response::json($decoded); } - return ToolResult::text($text); + return Response::text($text); } } - return ToolResult::text(''); + return Response::text(''); } /** diff --git a/src/Mcp/Tools/ApplicationInfo.php b/src/Mcp/Tools/ApplicationInfo.php index de000bc1..429a36fd 100644 --- a/src/Mcp/Tools/ApplicationInfo.php +++ b/src/Mcp/Tools/ApplicationInfo.php @@ -4,11 +4,12 @@ namespace Laravel\Boost\Mcp\Tools; +use Illuminate\JsonSchema\JsonSchema; use Laravel\Boost\Install\GuidelineAssist; +use Laravel\Mcp\Request; +use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; -use Laravel\Mcp\Server\Tools\ToolInputSchema; -use Laravel\Mcp\Server\Tools\ToolResult; use Laravel\Roster\Package; use Laravel\Roster\Roster; @@ -19,22 +20,18 @@ public function __construct(protected Roster $roster, protected GuidelineAssist { } - public function description(): string - { - return 'Get comprehensive application information including PHP version, Laravel version, database engine, all installed packages with their versions, and all Eloquent models in the application. You should use this tool on each new chat, and use the package & version data to write version specific code for the packages that exist.'; - } + /** + * The tool's description. + */ + protected string $description = 'Get comprehensive application information including PHP version, Laravel version, database engine, all installed packages with their versions, and all Eloquent models in the application. You should use this tool on each new chat, and use the package & version data to write version specific code for the packages that exist.'; - public function schema(ToolInputSchema $schema): ToolInputSchema - { - return $schema; - } /** - * @param array $arguments + * Handle the tool request. */ - public function handle(array $arguments): ToolResult + public function handle(Request $request): Response { - return ToolResult::json([ + return Response::json([ 'php_version' => PHP_VERSION, 'laravel_version' => app()->version(), 'database_engine' => config('database.default'), diff --git a/src/Mcp/Tools/BrowserLogs.php b/src/Mcp/Tools/BrowserLogs.php index 205715a3..4b316130 100644 --- a/src/Mcp/Tools/BrowserLogs.php +++ b/src/Mcp/Tools/BrowserLogs.php @@ -4,60 +4,66 @@ namespace Laravel\Boost\Mcp\Tools; +use Illuminate\JsonSchema\JsonSchema; use Laravel\Boost\Concerns\ReadsLogs; +use Laravel\Mcp\Request; +use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; -use Laravel\Mcp\Server\Tools\ToolInputSchema; -use Laravel\Mcp\Server\Tools\ToolResult; #[IsReadOnly] class BrowserLogs extends Tool { use ReadsLogs; - public function description(): string - { - return 'Read the last N log entries from the BROWSER log. Very helpful for debugging the frontend and JS/Javascript'; - } + /** + * The tool's description. + */ + protected string $description = 'Read the last N log entries from the BROWSER log. Very helpful for debugging the frontend and JS/Javascript'; - public function schema(ToolInputSchema $schema): ToolInputSchema + /** + * Get the tool's input schema. + * + * @return array + */ + public function schema(JsonSchema $schema): array { - $schema->integer('entries') - ->description('Number of log entries to return.') - ->required(); - - return $schema; + return [ + 'entries' => $schema->integer() + ->description('Number of log entries to return.') + ->required(), + ]; } /** - * @param array $arguments + * Handle the tool request. */ - public function handle(array $arguments): ToolResult + public function handle(Request $request): Response { - $maxEntries = (int) $arguments['entries']; + $maxEntries = $request->integer('entries'); if ($maxEntries <= 0) { - return ToolResult::error('The "entries" argument must be greater than 0.'); + return Response::error('The "entries" argument must be greater than 0.'); } // Locate the correct log file using the shared helper. $logFile = storage_path('logs/browser.log'); if (! file_exists($logFile)) { - return ToolResult::error('No log file found, probably means no logs yet.'); + return Response::error('No log file found, probably means no logs yet.'); } $entries = $this->readLastLogEntries($logFile, $maxEntries); if ($entries === []) { - return ToolResult::text('Unable to retrieve log entries, or no logs'); + return Response::text('Unable to retrieve log entries, or no logs'); } $logs = implode("\n\n", $entries); if (empty(trim($logs))) { - return ToolResult::text('No log entries yet.'); + return Response::text('No log entries yet.'); } - return ToolResult::text($logs); + return Response::text($logs); } } diff --git a/src/Mcp/Tools/DatabaseConnections.php b/src/Mcp/Tools/DatabaseConnections.php index 6217662f..3687d135 100644 --- a/src/Mcp/Tools/DatabaseConnections.php +++ b/src/Mcp/Tools/DatabaseConnections.php @@ -4,32 +4,29 @@ namespace Laravel\Boost\Mcp\Tools; +use Illuminate\JsonSchema\JsonSchema; +use Laravel\Mcp\Request; +use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; -use Laravel\Mcp\Server\Tools\ToolInputSchema; -use Laravel\Mcp\Server\Tools\ToolResult; #[IsReadOnly] class DatabaseConnections extends Tool { - public function description(): string - { - return 'List the configured database connection names for this application.'; - } + /** + * The tool's description. + */ + protected string $description = 'List the configured database connection names for this application.'; - public function schema(ToolInputSchema $schema): ToolInputSchema - { - return $schema; - } /** - * @param array $arguments + * Handle the tool request. */ - public function handle(array $arguments): ToolResult + public function handle(Request $request): Response { $connections = array_keys(config('database.connections', [])); - return ToolResult::json([ + return Response::json([ 'default_connection' => config('database.default'), 'connections' => $connections, ]); diff --git a/src/Mcp/Tools/DatabaseQuery.php b/src/Mcp/Tools/DatabaseQuery.php index caa28a12..93e8eb0a 100644 --- a/src/Mcp/Tools/DatabaseQuery.php +++ b/src/Mcp/Tools/DatabaseQuery.php @@ -4,49 +4,47 @@ namespace Laravel\Boost\Mcp\Tools; +use Illuminate\JsonSchema\JsonSchema; use Illuminate\Support\Facades\DB; +use Laravel\Mcp\Request; +use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; -use Laravel\Mcp\Server\Tools\ToolInputSchema; -use Laravel\Mcp\Server\Tools\ToolResult; use Throwable; #[IsReadOnly] class DatabaseQuery extends Tool { /** - * Get a short, human-readable description of what the tool does. + * The tool's description. */ - public function description(): string - { - return 'Execute a read-only SQL query against the configured database.'; - } + protected string $description = 'Execute a read-only SQL query against the configured database.'; /** - * Define the input schema for the tool. + * Get the tool's input schema. + * + * @return array */ - public function schema(ToolInputSchema $schema): ToolInputSchema + public function schema(JsonSchema $schema): array { - $schema->string('query') - ->description('The SQL query to execute. Only read-only queries are allowed (i.e. SELECT, SHOW, EXPLAIN, DESCRIBE).') - ->required(); - - $schema->string('database') - ->description("Optional database connection name to use. Defaults to the application's default connection.") - ->required(false); - - return $schema; + return [ + 'query' => $schema->string() + ->description('The SQL query to execute. Only read-only queries are allowed (i.e. SELECT, SHOW, EXPLAIN, DESCRIBE).') + ->required(), + 'database' => $schema->string() + ->description("Optional database connection name to use. Defaults to the application's default connection."), + ]; } /** - * @param array $arguments + * Handle the tool request. */ - public function handle(array $arguments): ToolResult + public function handle(Request $request): Response { - $query = trim($arguments['query']); + $query = trim((string) $request->string('query')); $token = strtok(ltrim($query), " \t\n\r"); if (! $token) { - return ToolResult::error('Please pass a valid query'); + return Response::error('Please pass a valid query'); } $firstWord = strtoupper($token); @@ -72,17 +70,17 @@ public function handle(array $arguments): ToolResult } if (! $isReadOnly) { - return ToolResult::error('Only read-only queries are allowed (SELECT, SHOW, EXPLAIN, DESCRIBE, DESC, WITH … SELECT).'); + return Response::error('Only read-only queries are allowed (SELECT, SHOW, EXPLAIN, DESCRIBE, DESC, WITH … SELECT).'); } - $connectionName = $arguments['database'] ?? null; + $connectionName = $request->get('database'); try { - return ToolResult::json( + return Response::json( DB::connection($connectionName)->select($query) ); } catch (Throwable $e) { - return ToolResult::error('Query failed: '.$e->getMessage()); + return Response::error('Query failed: '.$e->getMessage()); } } } diff --git a/src/Mcp/Tools/DatabaseSchema.php b/src/Mcp/Tools/DatabaseSchema.php index b4ee75a7..124f3462 100644 --- a/src/Mcp/Tools/DatabaseSchema.php +++ b/src/Mcp/Tools/DatabaseSchema.php @@ -5,52 +5,55 @@ namespace Laravel\Boost\Mcp\Tools; use Exception; +use Illuminate\JsonSchema\JsonSchema; use Illuminate\Support\Arr; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Schema; use Laravel\Boost\Mcp\Tools\DatabaseSchema\SchemaDriverFactory; +use Laravel\Mcp\Request; +use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; -use Laravel\Mcp\Server\Tools\ToolInputSchema; -use Laravel\Mcp\Server\Tools\ToolResult; #[IsReadOnly] class DatabaseSchema extends Tool { - public function description(): string - { - return 'Read the database schema for this application, including table names, columns, data types, indexes, foreign keys, and more.'; - } + /** + * The tool's description. + */ + protected string $description = 'Read the database schema for this application, including table names, columns, data types, indexes, foreign keys, and more.'; - public function schema(ToolInputSchema $schema): ToolInputSchema + /** + * Get the tool's input schema. + * + * @return array + */ + public function schema(JsonSchema $schema): array { - $schema->string('database') - ->description('Name of the database connection to dump (defaults to app\'s default connection, often not needed)') - ->required(false); - - $schema->string('filter') - ->description('Filter the tables by name') - ->required(false); - - return $schema; + return [ + 'database' => $schema->string() + ->description('Name of the database connection to dump (defaults to app\'s default connection, often not needed)'), + 'filter' => $schema->string() + ->description('Filter the tables by name'), + ]; } /** - * @param array $arguments + * Handle the tool request. */ - public function handle(array $arguments): ToolResult + public function handle(Request $request): Response { - $connection = $arguments['database'] ?? config('database.default'); - $filter = $arguments['filter'] ?? ''; + $connection = $request->get('database') ?? config('database.default'); + $filter = $request->get('filter') ?? ''; $cacheKey = "boost:mcp:database-schema:{$connection}:{$filter}"; $schema = Cache::remember($cacheKey, 20, function () use ($connection, $filter) { return $this->getDatabaseStructure($connection, $filter); }); - return ToolResult::json($schema); + return Response::json($schema); } protected function getDatabaseStructure(?string $connection, string $filter = ''): array diff --git a/src/Mcp/Tools/GetAbsoluteUrl.php b/src/Mcp/Tools/GetAbsoluteUrl.php index 744894d2..300da97d 100644 --- a/src/Mcp/Tools/GetAbsoluteUrl.php +++ b/src/Mcp/Tools/GetAbsoluteUrl.php @@ -4,49 +4,51 @@ namespace Laravel\Boost\Mcp\Tools; -use Illuminate\Support\Arr; +use Illuminate\JsonSchema\JsonSchema; +use Laravel\Mcp\Request; +use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; -use Laravel\Mcp\Server\Tools\ToolInputSchema; -use Laravel\Mcp\Server\Tools\ToolResult; #[IsReadOnly] class GetAbsoluteUrl extends Tool { - public function description(): string - { - return 'Get the absolute URL for a given relative path or named route. If no arguments are provided, you will get the absolute URL for "/"'; - } + /** + * The tool's description. + */ + protected string $description = 'Get the absolute URL for a given relative path or named route. If no arguments are provided, you will get the absolute URL for "/"'; - public function schema(ToolInputSchema $schema): ToolInputSchema + /** + * Get the tool's input schema. + * + * @return array + */ + public function schema(JsonSchema $schema): array { - $schema->string('path') - ->description('The relative URL/path (e.g. "/dashboard") to convert to an absolute URL.') - ->required(false); - - $schema->string('route') - ->description('The named route to generate an absolute URL for (e.g. "home").') - ->required(false); - - return $schema; + return [ + 'path' => $schema->string() + ->description('The relative URL/path (e.g. "/dashboard") to convert to an absolute URL.'), + 'route' => $schema->string() + ->description('The named route to generate an absolute URL for (e.g. "home").'), + ]; } /** - * @param array $arguments + * Handle the tool request. */ - public function handle(array $arguments): ToolResult + public function handle(Request $request): Response { - $path = Arr::get($arguments, 'path'); - $routeName = Arr::get($arguments, 'route'); + $path = $request->get('path'); + $routeName = $request->get('route'); if ($path) { - return ToolResult::text(url($path)); + return Response::text(url($path)); } if ($routeName) { - return ToolResult::text(route($routeName)); + return Response::text(route($routeName)); } - return ToolResult::text(url('/')); + return Response::text(url('/')); } } diff --git a/src/Mcp/Tools/GetConfig.php b/src/Mcp/Tools/GetConfig.php index 04eee15a..1d3d9c9b 100644 --- a/src/Mcp/Tools/GetConfig.php +++ b/src/Mcp/Tools/GetConfig.php @@ -4,40 +4,45 @@ namespace Laravel\Boost\Mcp\Tools; +use Illuminate\JsonSchema\JsonSchema; use Illuminate\Support\Facades\Config; +use Laravel\Mcp\Request; +use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; -use Laravel\Mcp\Server\Tools\ToolInputSchema; -use Laravel\Mcp\Server\Tools\ToolResult; #[IsReadOnly] class GetConfig extends Tool { - public function description(): string - { - return 'Get the value of a specific config variable using dot notation (e.g., "app.name", "database.default")'; - } + protected string $description = 'Get the value of a specific config variable using dot notation (e.g., "app.name", "database.default")'; - public function schema(ToolInputSchema $schema): ToolInputSchema + /** + * Get the tool's input schema. + * + * @return array + */ + public function schema(JsonSchema $schema): array { - return $schema - ->string('key') - ->description('The config key in dot notation (e.g., "app.name", "database.default")') - ->required(); + return [ + 'key' => $schema + ->string() + ->description('The config key in dot notation (e.g., "app.name", "database.default")') + ->required(), + ]; } /** - * @param array $arguments + * Handle the tool request. */ - public function handle(array $arguments): ToolResult + public function handle(Request $request): Response { - $key = $arguments['key']; + $key = $request->get('key'); if (! Config::has($key)) { - return ToolResult::error("Config key '{$key}' not found."); + return Response::error("Config key '{$key}' not found."); } - return ToolResult::json([ + return Response::json([ 'key' => $key, 'value' => Config::get($key), ]); diff --git a/src/Mcp/Tools/LastError.php b/src/Mcp/Tools/LastError.php index 76c8e973..dc640516 100644 --- a/src/Mcp/Tools/LastError.php +++ b/src/Mcp/Tools/LastError.php @@ -4,14 +4,15 @@ namespace Laravel\Boost\Mcp\Tools; +use Illuminate\JsonSchema\JsonSchema; use Illuminate\Log\Events\MessageLogged; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Log; use Laravel\Boost\Concerns\ReadsLogs; +use Laravel\Mcp\Request; +use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; -use Laravel\Mcp\Server\Tools\ToolInputSchema; -use Laravel\Mcp\Server\Tools\ToolResult; #[IsReadOnly] class LastError extends Tool @@ -42,20 +43,16 @@ public function __construct() } } - public function description(): string - { - return 'Get details of the last error/exception created in this application on the backend. Use browser-log tool for browser errors.'; - } + /** + * The tool's description. + */ + protected string $description = 'Get details of the last error/exception created in this application on the backend. Use browser-log tool for browser errors.'; - public function schema(ToolInputSchema $schema): ToolInputSchema - { - return $schema; - } /** - * @param array $arguments + * Handle the tool request. */ - public function handle(array $arguments): ToolResult + public function handle(Request $request): Response { // First, attempt to retrieve the cached last error captured during runtime. // This works even if the log driver isn't a file driver, so is the preferred approach @@ -66,22 +63,22 @@ public function handle(array $arguments): ToolResult $entry .= ' '.json_encode($cached['context']); } - return ToolResult::text($entry); + return Response::text($entry); } // Locate the correct log file using the shared helper. $logFile = $this->resolveLogFilePath(); if (! file_exists($logFile)) { - return ToolResult::error("Log file not found at {$logFile}"); + return Response::error("Log file not found at {$logFile}"); } $entry = $this->readLastErrorEntry($logFile); if ($entry !== null) { - return ToolResult::text($entry); + return Response::text($entry); } - return ToolResult::error('Unable to find an ERROR entry in the inspected portion of the log file.'); + return Response::error('Unable to find an ERROR entry in the inspected portion of the log file.'); } } diff --git a/src/Mcp/Tools/ListArtisanCommands.php b/src/Mcp/Tools/ListArtisanCommands.php index 647210b2..5483aa45 100644 --- a/src/Mcp/Tools/ListArtisanCommands.php +++ b/src/Mcp/Tools/ListArtisanCommands.php @@ -5,29 +5,26 @@ namespace Laravel\Boost\Mcp\Tools; use Illuminate\Console\Command; +use Illuminate\JsonSchema\JsonSchema; use Illuminate\Support\Facades\Artisan; +use Laravel\Mcp\Request; +use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; -use Laravel\Mcp\Server\Tools\ToolInputSchema; -use Laravel\Mcp\Server\Tools\ToolResult; #[IsReadOnly] class ListArtisanCommands extends Tool { - public function description(): string - { - return 'List all available Artisan commands registered in this application.'; - } + /** + * The tool's description. + */ + protected string $description = 'List all available Artisan commands registered in this application.'; - public function schema(ToolInputSchema $schema): ToolInputSchema - { - return $schema; - } /** - * @param array $arguments + * Handle the tool request. */ - public function handle(array $arguments): ToolResult + public function handle(Request $request): Response { $commands = Artisan::all(); @@ -43,6 +40,6 @@ public function handle(array $arguments): ToolResult // Sort alphabetically by name for determinism. usort($commandList, fn ($firstCommand, $secondCommand) => strcmp($firstCommand['name'], $secondCommand['name'])); - return ToolResult::json($commandList); + return Response::json($commandList); } } diff --git a/src/Mcp/Tools/ListAvailableConfigKeys.php b/src/Mcp/Tools/ListAvailableConfigKeys.php index 8e8bc3f7..1ea5e3dd 100644 --- a/src/Mcp/Tools/ListAvailableConfigKeys.php +++ b/src/Mcp/Tools/ListAvailableConfigKeys.php @@ -4,35 +4,32 @@ namespace Laravel\Boost\Mcp\Tools; +use Illuminate\JsonSchema\JsonSchema; use Illuminate\Support\Facades\Config; +use Laravel\Mcp\Request; +use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; -use Laravel\Mcp\Server\Tools\ToolInputSchema; -use Laravel\Mcp\Server\Tools\ToolResult; #[IsReadOnly] class ListAvailableConfigKeys extends Tool { - public function description(): string - { - return 'List all available Laravel configuration keys (from config/*.php) in dot notation.'; - } + /** + * The tool's description. + */ + protected string $description = 'List all available Laravel configuration keys (from config/*.php) in dot notation.'; - public function schema(ToolInputSchema $schema): ToolInputSchema - { - return $schema; - } /** - * @param array $arguments + * Handle the tool request. */ - public function handle(array $arguments): ToolResult + public function handle(Request $request): Response { $configArray = Config::all(); $dotKeys = $this->flattenToDotNotation($configArray); sort($dotKeys); - return ToolResult::json($dotKeys); + return Response::json($dotKeys); } /** diff --git a/src/Mcp/Tools/ListAvailableEnvVars.php b/src/Mcp/Tools/ListAvailableEnvVars.php index e9498b25..183d59d9 100644 --- a/src/Mcp/Tools/ListAvailableEnvVars.php +++ b/src/Mcp/Tools/ListAvailableEnvVars.php @@ -4,62 +4,66 @@ namespace Laravel\Boost\Mcp\Tools; -use Illuminate\Support\Arr; +use Illuminate\JsonSchema\JsonSchema; +use Laravel\Mcp\Request; +use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; -use Laravel\Mcp\Server\Tools\ToolInputSchema; -use Laravel\Mcp\Server\Tools\ToolResult; #[IsReadOnly] class ListAvailableEnvVars extends Tool { - public function description(): string - { - return '🔧 List all available environment variable names from a given .env file (default .env).'; - } + /** + * The tool's description. + */ + protected string $description = '🔧 List all available environment variable names from a given .env file (default .env).'; - public function schema(ToolInputSchema $schema): ToolInputSchema + /** + * Get the tool's input schema. + * + * @return array + */ + public function schema(JsonSchema $schema): array { - $schema->string('filename') - ->description('The name of the .env file to read (e.g. .env, .env.example). Defaults to .env if not provided.') - ->required(false); - - return $schema; + return [ + 'filename' => $schema->string() + ->description('The name of the .env file to read (e.g. .env, .env.example). Defaults to .env if not provided.'), + ]; } /** - * @param array $arguments + * Handle the tool request. */ - public function handle(array $arguments): ToolResult + public function handle(Request $request): Response { - $filename = Arr::get($arguments, 'filename', '.env'); + $filename = $request->get('filename', '.env'); $filePath = base_path($filename); if (! str_contains($filePath, '.env')) { - return ToolResult::error('This tool can only read .env files'); + return Response::error('This tool can only read .env files'); } if (! file_exists($filePath)) { - return ToolResult::error("File not found at '{$filePath}'"); + return Response::error("File not found at '{$filePath}'"); } $envLines = file_get_contents($filePath); if (! $envLines) { - return ToolResult::error('Failed to read .env file.'); + return Response::error('Failed to read .env file.'); } $count = preg_match_all('/^(?!\s*#)\s*([^=\s]+)=/m', $envLines, $matches); if (! $count) { - return ToolResult::error('Failed to parse .env file'); + return Response::error('Failed to parse .env file'); } $envVars = array_map('trim', $matches[1]); sort($envVars); - return ToolResult::json($envVars); + return Response::json($envVars); } } diff --git a/src/Mcp/Tools/ListRoutes.php b/src/Mcp/Tools/ListRoutes.php index a1baf04c..f4c58fed 100644 --- a/src/Mcp/Tools/ListRoutes.php +++ b/src/Mcp/Tools/ListRoutes.php @@ -4,42 +4,46 @@ namespace Laravel\Boost\Mcp\Tools; +use Illuminate\JsonSchema\JsonSchema; use Illuminate\Support\Facades\Artisan; +use Laravel\Mcp\Request; +use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; -use Laravel\Mcp\Server\Tools\ToolInputSchema; -use Laravel\Mcp\Server\Tools\ToolResult; use Symfony\Component\Console\Command\Command as CommandAlias; use Symfony\Component\Console\Output\BufferedOutput; #[IsReadOnly] class ListRoutes extends Tool { - public function description(): string - { - return 'List all available routes defined in the application, including Folio routes if used'; - } + /** + * The tool's description. + */ + protected string $description = 'List all available routes defined in the application, including Folio routes if used'; - public function schema(ToolInputSchema $schema): ToolInputSchema + /** + * Get the tool's input schema. + * + * @return array + */ + public function schema(JsonSchema $schema): array { - // Mirror the most common `route:list` options. All are optional. - $schema->string('method')->description('Filter the routes by HTTP method (e.g., GET, POST, PUT, DELETE).')->required(false); - $schema->string('action')->description('Filter the routes by controller action (e.g., UserController@index, ChatController, show).')->required(false); - $schema->string('name')->description('Filter the routes by route name (no wildcards supported).')->required(false); - $schema->string('domain')->description('Filter the routes by domain.')->required(false); - $schema->string('path')->description('Only show routes matching the given path pattern.')->required(false); - // Keys with hyphens are converted to underscores for PHP variable compatibility. - $schema->string('except_path')->description('Do not display the routes matching the given path pattern.')->required(false); - $schema->boolean('except_vendor')->description('Do not display routes defined by vendor packages.')->required(false); - $schema->boolean('only_vendor')->description('Only display routes defined by vendor packages.')->required(false); - - return $schema; + return [ + 'method' => $schema->string()->description('Filter the routes by HTTP method (e.g., GET, POST, PUT, DELETE).'), + 'action' => $schema->string()->description('Filter the routes by controller action (e.g., UserController@index, ChatController, show).'), + 'name' => $schema->string()->description('Filter the routes by route name (no wildcards supported).'), + 'domain' => $schema->string()->description('Filter the routes by domain.'), + 'path' => $schema->string()->description('Only show routes matching the given path pattern.'), + 'except_path' => $schema->string()->description('Do not display the routes matching the given path pattern.'), + 'except_vendor' => $schema->boolean()->description('Do not display routes defined by vendor packages.'), + 'only_vendor' => $schema->boolean()->description('Only display routes defined by vendor packages.'), + ]; } /** - * @param array $arguments + * Handle the tool request. */ - public function handle(array $arguments): ToolResult + public function handle(Request $request): Response { $optionMap = [ 'method' => 'method', @@ -58,8 +62,9 @@ public function handle(array $arguments): ToolResult ]; foreach ($optionMap as $argKey => $cliOption) { - if (! empty($arguments[$argKey])) { - $sanitizedValue = str_replace(['*', '?'], '', $arguments[$argKey]); + $value = $request->get($argKey); + if (! empty($value)) { + $sanitizedValue = str_replace(['*', '?'], '', $value); if (filled($sanitizedValue)) { $options['--'.$cliOption] = $sanitizedValue; } @@ -78,7 +83,7 @@ public function handle(array $arguments): ToolResult $routesOutput .= $this->artisan('folio:list', $folioOptions); } - return ToolResult::text($routesOutput); + return Response::text($routesOutput); } /** @@ -87,8 +92,8 @@ public function handle(array $arguments): ToolResult private function artisan(string $command, array $options = []): string { $output = new BufferedOutput; - $result = Artisan::call($command, $options, $output); - if ($result !== CommandAlias::SUCCESS) { + $response = Artisan::call($command, $options, $output); + if ($response !== CommandAlias::SUCCESS) { return 'Failed to list routes: '.$output->fetch(); } diff --git a/src/Mcp/Tools/ReadLogEntries.php b/src/Mcp/Tools/ReadLogEntries.php index 223a4aab..21eee16f 100644 --- a/src/Mcp/Tools/ReadLogEntries.php +++ b/src/Mcp/Tools/ReadLogEntries.php @@ -4,61 +4,67 @@ namespace Laravel\Boost\Mcp\Tools; +use Illuminate\JsonSchema\JsonSchema; use Laravel\Boost\Concerns\ReadsLogs; +use Laravel\Mcp\Request; +use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; -use Laravel\Mcp\Server\Tools\ToolInputSchema; -use Laravel\Mcp\Server\Tools\ToolResult; #[IsReadOnly] class ReadLogEntries extends Tool { use ReadsLogs; - public function description(): string - { - return 'Read the last N log entries from the application log, correctly handling multi-line PSR-3 formatted logs. Only works for log files.'; - } + /** + * The tool's description. + */ + protected string $description = 'Read the last N log entries from the application log, correctly handling multi-line PSR-3 formatted logs. Only works for log files.'; - public function schema(ToolInputSchema $schema): ToolInputSchema + /** + * Get the tool's input schema. + * + * @return array + */ + public function schema(JsonSchema $schema): array { - $schema->integer('entries') - ->description('Number of log entries to return.') - ->required(); - - return $schema; + return [ + 'entries' => $schema->integer() + ->description('Number of log entries to return.') + ->required(), + ]; } /** - * @param array $arguments + * Handle the tool request. */ - public function handle(array $arguments): ToolResult + public function handle(Request $request): Response { - $maxEntries = (int) $arguments['entries']; + $maxEntries = (int) $request->get('entries'); if ($maxEntries <= 0) { - return ToolResult::error('The "entries" argument must be greater than 0.'); + return Response::error('The "entries" argument must be greater than 0.'); } // Determine log file path via helper. $logFile = $this->resolveLogFilePath(); if (! file_exists($logFile)) { - return ToolResult::error("Log file not found at {$logFile}"); + return Response::error("Log file not found at {$logFile}"); } $entries = $this->readLastLogEntries($logFile, $maxEntries); if ($entries === []) { - return ToolResult::text('Unable to retrieve log entries, or no entries yet.'); + return Response::text('Unable to retrieve log entries, or no entries yet.'); } $logs = implode("\n\n", $entries); if (empty(trim($logs))) { - return ToolResult::text('No log entries yet.'); + return Response::text('No log entries yet.'); } - return ToolResult::text($logs); + return Response::text($logs); } // The isNewLogEntry and readLinesReverse helper methods are now provided by the ReadsLogs trait. diff --git a/src/Mcp/Tools/ReportFeedback.php b/src/Mcp/Tools/ReportFeedback.php index b7b16372..cde60426 100644 --- a/src/Mcp/Tools/ReportFeedback.php +++ b/src/Mcp/Tools/ReportFeedback.php @@ -5,38 +5,43 @@ namespace Laravel\Boost\Mcp\Tools; use Generator; +use Illuminate\JsonSchema\JsonSchema; use Laravel\Boost\Concerns\MakesHttpRequests; +use Laravel\Mcp\Request; +use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; -use Laravel\Mcp\Server\Tools\ToolInputSchema; -use Laravel\Mcp\Server\Tools\ToolResult; class ReportFeedback extends Tool { use MakesHttpRequests; - public function description(): string - { - return 'Report feedback from the user on what would make Boost, or their experience with Laravel, better. Ask the user for more details before use if ambiguous or unclear. This is only for feedback related to Boost or the Laravel ecosystem.'.PHP_EOL.'Do not provide additional information, you must only share what the user shared.'; - } + protected string $description = 'Report feedback from the user on what would make Boost, or their experience with Laravel, better. Ask the user for more details before use if ambiguous or unclear. This is only for feedback related to Boost or the Laravel ecosystem.'.PHP_EOL.'Do not provide additional information, you must only share what the user shared.'; - public function schema(ToolInputSchema $schema): ToolInputSchema + /** + * Get the tool's input schema. + * + * @return array + */ + public function schema(JsonSchema $schema): array { - return $schema - ->string('feedback') - ->description('Detailed feedback from the user on what would make Boost, or their experience with Laravel, better. Ask the user for more details if ambiguous or unclear.') - ->required(); + return [ + 'feedback' => $schema + ->string() + ->description('Detailed feedback from the user on what would make Boost, or their experience with Laravel, better. Ask the user for more details if ambiguous or unclear.') + ->required(), + ]; } /** - * @param array $arguments + * Handle the tool request. */ - public function handle(array $arguments): ToolResult|Generator + public function handle(Request $request): Response|Generator { $apiUrl = config('boost.hosted.api_url', 'https://boost.laravel.com').'/api/feedback'; - $feedback = $arguments['feedback']; + $feedback = $request->get('feedback'); if (empty($feedback) || strlen($feedback) < 10) { - return ToolResult::error('Feedback too short'); + return Response::error('Feedback too short'); } $response = $this->json($apiUrl, [ @@ -44,9 +49,9 @@ public function handle(array $arguments): ToolResult|Generator ]); if ($response->successful() === false) { - return ToolResult::error('Failed to share feedback, apologies'); + return Response::error('Failed to share feedback, apologies'); } - return ToolResult::text('Feedback shared, thank you for helping Boost & Laravel get better.'); + return Response::text('Feedback shared, thank you for helping Boost & Laravel get better.'); } } diff --git a/src/Mcp/Tools/SearchDocs.php b/src/Mcp/Tools/SearchDocs.php index baf8ca0e..3b7ac3bc 100644 --- a/src/Mcp/Tools/SearchDocs.php +++ b/src/Mcp/Tools/SearchDocs.php @@ -5,10 +5,11 @@ namespace Laravel\Boost\Mcp\Tools; use Generator; +use Illuminate\JsonSchema\JsonSchema; use Laravel\Boost\Concerns\MakesHttpRequests; +use Laravel\Mcp\Request; +use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; -use Laravel\Mcp\Server\Tools\ToolInputSchema; -use Laravel\Mcp\Server\Tools\ToolResult; use Laravel\Roster\Package; use Laravel\Roster\Roster; @@ -20,46 +21,41 @@ public function __construct(protected Roster $roster) { } - public function description(): string - { - return 'Search for up-to-date version-specific documentation related to this project and its packages. This tool will search Laravel hosted documentation based on the packages installed and is perfect for all Laravel ecosystem packages. Laravel, Inertia, Pest, Livewire, Filament, Nova, Tailwind, and more.'.PHP_EOL.'You must use this tool to search for Laravel-ecosystem docs before using other approaches. The results provided are for this project\'s package version and does not cover all versions of the package.'; - } + /** + * The tool's description. + */ + protected string $description = 'Search for up-to-date version-specific documentation related to this project and its packages. This tool will search Laravel hosted documentation based on the packages installed and is perfect for all Laravel ecosystem packages. Laravel, Inertia, Pest, Livewire, Filament, Nova, Tailwind, and more. You must use this tool to search for Laravel-ecosystem docs before using other approaches. The results provided are for this project\'s package version and does not cover all versions of the package.'; - public function schema(ToolInputSchema $schema): ToolInputSchema + /** + * Get the tool's input schema. + * + * @return array + */ + public function schema(JsonSchema $schema): array { - return $schema - ->raw('queries', [ - 'description' => 'List of queries to perform, pass multiple if you aren\'t sure if it is "toggle" or "switch", for example', - 'type' => 'array', - 'items' => [ - 'type' => 'string', - 'description' => 'Search query', - ], - ])->required() - ->raw('packages', [ - 'description' => 'Package names to limit searching to from application-info. Useful if you know the package(s) you need. i.e. laravel/framework, inertiajs/inertia-laravel, @inertiajs/react', - 'type' => 'array', - 'items' => [ - 'type' => 'string', - 'description' => "The composer package name (e.g., 'symfony/console')", - ], - ]) - - ->integer('token_limit') - ->description('Maximum number of tokens to return in the response. Defaults to 10,000 tokens, maximum 1,000,000 tokens.') - ->optional(); + return [ + 'queries' => $schema->array() + ->items($schema->string()->description('Search query')) + ->description('List of queries to perform, pass multiple if you aren\'t sure if it is "toggle" or "switch", for example') + ->required(), + 'packages' => $schema->array() + ->items($schema->string()->description("The composer package name (e.g., 'symfony/console')")) + ->description('Package names to limit searching to from application-info. Useful if you know the package(s) you need. i.e. laravel/framework, inertiajs/inertia-laravel, @inertiajs/react'), + 'token_limit' => $schema->integer() + ->description('Maximum number of tokens to return in the response. Defaults to 10,000 tokens, maximum 1,000,000 tokens.'), + ]; } /** - * @param array $arguments + * Handle the tool request. */ - public function handle(array $arguments): ToolResult|Generator + public function handle(Request $request): Response|Generator { $apiUrl = config('boost.hosted.api_url', 'https://boost.laravel.com').'/api/docs'; - $packagesFilter = array_key_exists('packages', $arguments) ? $arguments['packages'] : null; + $packagesFilter = $request->get('packages'); $queries = array_filter( - array_map('trim', $arguments['queries']), + array_map('trim', $request->get('queries')), fn ($query) => $query !== '' && $query !== '*' ); @@ -83,10 +79,10 @@ public function handle(array $arguments): ToolResult|Generator $packages = $packages->values()->toArray(); } catch (\Throwable $e) { - return ToolResult::error('Failed to get packages: '.$e->getMessage()); + return Response::error('Failed to get packages: '.$e->getMessage()); } - $tokenLimit = $arguments['token_limit'] ?? 10000; + $tokenLimit = $request->get('token_limit') ?? 10000; $tokenLimit = min($tokenLimit, 1000000); // Cap at 1M tokens $payload = [ @@ -100,12 +96,12 @@ public function handle(array $arguments): ToolResult|Generator $response = $this->client()->asJson()->post($apiUrl, $payload); if (! $response->successful()) { - return ToolResult::error('Failed to search documentation: '.$response->body()); + return Response::error('Failed to search documentation: '.$response->body()); } } catch (\Throwable $e) { - return ToolResult::error('HTTP request failed: '.$e->getMessage()); + return Response::error('HTTP request failed: '.$e->getMessage()); } - return ToolResult::text($response->body()); + return Response::text($response->body()); } } diff --git a/src/Mcp/Tools/Tinker.php b/src/Mcp/Tools/Tinker.php index 52d9004e..23c55372 100644 --- a/src/Mcp/Tools/Tinker.php +++ b/src/Mcp/Tools/Tinker.php @@ -5,42 +5,44 @@ namespace Laravel\Boost\Mcp\Tools; use Exception; -use Illuminate\Support\Arr; +use Illuminate\JsonSchema\JsonSchema; +use Laravel\Mcp\Request; +use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; -use Laravel\Mcp\Server\Tools\ToolInputSchema; -use Laravel\Mcp\Server\Tools\ToolResult; use Throwable; class Tinker extends Tool { - public function description(): string - { - return <<<'DESCRIPTION' -Execute PHP code in the Laravel application context, like artisan tinker. -Use this for debugging issues, checking if functions exist, and testing code snippets. -You should not create models directly without explicit user approval. Prefer Unit/Feature tests using factories for functionality testing. Prefer existing artisan commands over custom tinker code. -Returns the output of the code, as well as whatever is "returned" using "return". -DESCRIPTION; - } + /** + * The tool's description. + */ + protected string $description = 'Execute PHP code in the Laravel application context, like artisan tinker. Use this for debugging issues, checking if functions exist, and testing code snippets. You should not create models directly without explicit user approval. Prefer Unit/Feature tests using factories for functionality testing. Prefer existing artisan commands over custom tinker code. Returns the output of the code, as well as whatever is "returned" using "return".'; - public function schema(ToolInputSchema $schema): ToolInputSchema + /** + * Get the tool's input schema. + * + * @return array + */ + public function schema(JsonSchema $schema): array { - return $schema - ->string('code') - ->description('PHP code to execute (without opening required() - ->integer('timeout') - ->description('Maximum execution time in seconds (default: 180)'); + return [ + 'code' => $schema->string() + ->description('PHP code to execute (without opening required(), + 'timeout' => $schema->integer() + ->description('Maximum execution time in seconds (default: 180)') + ->required(), + ]; } /** - * @param array $arguments + * Handle the tool request. * * @throws Exception */ - public function handle(array $arguments): ToolResult + public function handle(Request $request): Response { - $code = str_replace([''], '', (string) Arr::get($arguments, 'code')); + $code = str_replace([''], '', (string) $request->get('code')); ini_set('memory_limit', '256M'); @@ -62,9 +64,9 @@ public function handle(array $arguments): ToolResult $response['class'] = get_class($result); } - return ToolResult::json($response); + return Response::json($response); } catch (Throwable $e) { - return ToolResult::json([ + return Response::json([ 'error' => $e->getMessage(), 'type' => get_class($e), 'file' => $e->getFile(), diff --git a/tests/Feature/Mcp/ToolExecutorTest.php b/tests/Feature/Mcp/ToolExecutorTest.php index 172fd7fe..08897172 100644 --- a/tests/Feature/Mcp/ToolExecutorTest.php +++ b/tests/Feature/Mcp/ToolExecutorTest.php @@ -3,7 +3,7 @@ use Laravel\Boost\Mcp\ToolExecutor; use Laravel\Boost\Mcp\Tools\GetConfig; use Laravel\Boost\Mcp\Tools\Tinker; -use Laravel\Mcp\Server\Tools\ToolResult; +use Laravel\Mcp\Response; test('can execute tool in subprocess', function () { // Create a mock that overrides buildCommand to work with testbench @@ -13,30 +13,29 @@ ->once() ->andReturnUsing(fn ($toolClass, $arguments) => buildSubprocessCommand($toolClass, $arguments)); - $result = $executor->execute(GetConfig::class, ['key' => 'app.name']); + $response = $executor->execute(GetConfig::class, ['key' => 'app.name']); - expect($result)->toBeInstanceOf(ToolResult::class); + expect($response)->toBeInstanceOf(Response::class); - // If there's an error, extract the text content properly - if ($result->isError) { - $errorText = $result->content[0]->text ?? 'Unknown error'; + // If there's an error, show the error message + if ($response->isError()) { + $errorText = (string) $response->content(); expect(false)->toBeTrue("Tool execution failed with error: {$errorText}"); } - expect($result->isError)->toBeFalse() - ->and($result->content)->toBeArray(); + expect($response->isError())->toBeFalse(); // The content should contain the app name (which should be "Laravel" in testbench) - $textContent = $result->content[0]->text ?? ''; + $textContent = (string) $response->content(); expect($textContent)->toContain('Laravel'); }); test('rejects unregistered tools', function () { $executor = app(ToolExecutor::class); - $result = $executor->execute('NonExistentToolClass'); + $response = $executor->execute('NonExistentToolClass'); - expect($result)->toBeInstanceOf(ToolResult::class) - ->and($result->isError)->toBeTrue(); + expect($response)->toBeInstanceOf(Response::class) + ->and($response->isError())->toBeTrue(); }); test('subprocess proves fresh process isolation', function () { @@ -45,14 +44,14 @@ $executor->shouldReceive('buildCommand') ->andReturnUsing(fn ($toolClass, $arguments) => buildSubprocessCommand($toolClass, $arguments)); - $result1 = $executor->execute(Tinker::class, ['code' => 'return getmypid();']); - $result2 = $executor->execute(Tinker::class, ['code' => 'return getmypid();']); + $response1 = $executor->execute(Tinker::class, ['code' => 'return getmypid();']); + $response2 = $executor->execute(Tinker::class, ['code' => 'return getmypid();']); - expect($result1->isError)->toBeFalse() - ->and($result2->isError)->toBeFalse(); + expect($response1->isError())->toBeFalse() + ->and($response2->isError())->toBeFalse(); - $pid1 = json_decode($result1->content[0]->text, true)['result']; - $pid2 = json_decode($result2->content[0]->text, true)['result']; + $pid1 = json_decode((string) $response1->content(), true)['result']; + $pid2 = json_decode((string) $response2->content(), true)['result']; expect($pid1)->toBeInt()->not->toBe(getmypid()) ->and($pid2)->toBeInt()->not->toBe(getmypid()) @@ -75,11 +74,11 @@ }; try { - $result1 = $executor->execute(GetConfig::class, ['key' => 'app.name']); + $response1 = $executor->execute(GetConfig::class, ['key' => 'app.name']); - expect($result1->isError)->toBeFalse(); - $response1 = json_decode($result1->content[0]->text, true); - expect($response1['value'])->toBe('Laravel'); // Normal testbench app name + expect($response1->isError())->toBeFalse(); + $responseData1 = json_decode((string) $response1->content(), true); + expect($responseData1['value'])->toBe('Laravel'); // Normal testbench app name // Modify GetConfig.php to return a different hardcoded value $modifiedContent = str_replace( @@ -89,11 +88,11 @@ ); file_put_contents($toolPath, $modifiedContent); - $result2 = $executor->execute(GetConfig::class, ['key' => 'app.name']); - $response2 = json_decode($result2->content[0]->text, true); + $response2 = $executor->execute(GetConfig::class, ['key' => 'app.name']); + $responseData2 = json_decode((string) $response2->content(), true); - expect($result2->isError)->toBeFalse() - ->and($response2['value'])->toBe('MODIFIED_BY_TEST'); // Using updated code, not cached + expect($response2->isError())->toBeFalse() + ->and($responseData2['value'])->toBe('MODIFIED_BY_TEST'); // Using updated code, not cached } finally { $cleanup(); } @@ -140,12 +139,12 @@ function buildSubprocessCommand(string $toolClass, array $arguments): array ->andReturnUsing(fn ($toolClass, $arguments) => buildSubprocessCommand($toolClass, $arguments)); // Test with custom timeout - should succeed with fast code - $result = $executor->execute(Tinker::class, [ + $response = $executor->execute(Tinker::class, [ 'code' => 'return "timeout test";', 'timeout' => 30, ]); - expect($result->isError)->toBeFalse(); + expect($response->isError())->toBeFalse(); }); test('clamps timeout values correctly', function () { diff --git a/tests/Feature/Mcp/Tools/ApplicationInfoTest.php b/tests/Feature/Mcp/Tools/ApplicationInfoTest.php index a6736d3d..d0582f9d 100644 --- a/tests/Feature/Mcp/Tools/ApplicationInfoTest.php +++ b/tests/Feature/Mcp/Tools/ApplicationInfoTest.php @@ -4,6 +4,7 @@ use Laravel\Boost\Install\GuidelineAssist; use Laravel\Boost\Mcp\Tools\ApplicationInfo; +use Laravel\Mcp\Request; use Laravel\Roster\Enums\Packages; use Laravel\Roster\Package; use Laravel\Roster\PackageCollection; @@ -25,9 +26,9 @@ ]); $tool = new ApplicationInfo($roster, $guidelineAssist); - $result = $tool->handle([]); + $response = $tool->handle(new Request([])); - expect($result)->isToolResult() + expect($response)->isToolResult() ->toolHasNoError() ->toolJsonContentToMatchArray([ 'php_version' => PHP_VERSION, @@ -60,9 +61,9 @@ $guidelineAssist->shouldReceive('models')->andReturn([]); $tool = new ApplicationInfo($roster, $guidelineAssist); - $result = $tool->handle([]); + $response = $tool->handle(new Request([])); - expect($result)->isToolResult() + expect($response)->isToolResult() ->toolHasNoError() ->toolJsonContentToMatchArray([ 'php_version' => PHP_VERSION, diff --git a/tests/Feature/Mcp/Tools/BrowserLogsTest.php b/tests/Feature/Mcp/Tools/BrowserLogsTest.php index 34d75f05..91ae0bce 100644 --- a/tests/Feature/Mcp/Tools/BrowserLogsTest.php +++ b/tests/Feature/Mcp/Tools/BrowserLogsTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -use Illuminate\Http\Request; +use Illuminate\Http\Request as HttpRequest; use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Log; @@ -10,6 +10,7 @@ use Laravel\Boost\Mcp\Tools\BrowserLogs; use Laravel\Boost\Middleware\InjectBoost; use Laravel\Boost\Services\BrowserLogger; +use Laravel\Mcp\Request; beforeEach(function () { // Clean up any existing browser.log file before each test @@ -33,38 +34,38 @@ File::put($logFile, $logContent); $tool = new BrowserLogs; - $result = $tool->handle(['entries' => 2]); + $response = $tool->handle(new Request(['entries' => 2])); - expect($result)->isToolResult() + expect($response)->isToolResult() ->toolHasNoError() ->toolTextContains('browser.WARNING: Warning message', 'browser.ERROR: JavaScript error occurred') ->toolTextDoesNotContain('browser.DEBUG: console log message'); - $data = $result->toArray(); - expect($data['content'][0]['type'])->toBe('text'); + // Response objects don't have toArray(), just verify the text content + expect($response)->isToolResult(); }); test('it returns error when entries argument is invalid', function () { $tool = new BrowserLogs; // Test with zero - $result = $tool->handle(['entries' => 0]); - expect($result)->isToolResult() + $response = $tool->handle(new Request(['entries' => 0])); + expect($response)->isToolResult() ->toolHasError() ->toolTextContains('The "entries" argument must be greater than 0.'); // Test with negative - $result = $tool->handle(['entries' => -5]); - expect($result)->isToolResult() + $response = $tool->handle(new Request(['entries' => -5])); + expect($response)->isToolResult() ->toolHasError() ->toolTextContains('The "entries" argument must be greater than 0.'); }); test('it returns error when log file does not exist', function () { $tool = new BrowserLogs; - $result = $tool->handle(['entries' => 10]); + $response = $tool->handle(new Request(['entries' => 10])); - expect($result)->isToolResult() + expect($response)->isToolResult() ->toolHasError() ->toolTextContains('No log file found, probably means no logs yet.'); }); @@ -76,9 +77,9 @@ File::put($logFile, ''); $tool = new BrowserLogs; - $result = $tool->handle(['entries' => 5]); + $response = $tool->handle(new Request(['entries' => 5])); - expect($result)->isToolResult() + expect($response)->isToolResult() ->toolHasNoError() ->toolTextContains('Unable to retrieve log entries, or no logs'); }); @@ -195,7 +196,7 @@ HTML; - $request = Request::create('/'); + $request = HttpRequest::create('/'); $response = new \Illuminate\Http\Response($html, 200, ['Content-Type' => 'text/html']); $result = $middleware->handle($request, function ($req) use ($response) { @@ -214,7 +215,7 @@ $json = json_encode(['status' => 'ok']); - $request = Request::create('/'); + $request = HttpRequest::create('/'); $response = new \Illuminate\Http\Response($json); $result = $middleware->handle($request, function ($req) use ($response) { @@ -242,7 +243,7 @@ HTML; - $request = Request::create('/'); + $request = HttpRequest::create('/'); $response = new \Illuminate\Http\Response($html); $result = $middleware->handle($request, function ($req) use ($response) { @@ -265,7 +266,7 @@ HTML; - $request = Request::create('/'); + $request = HttpRequest::create('/'); $response = new \Illuminate\Http\Response($html, 200, ['Content-Type' => 'text/html']); $result = $middleware->handle($request, function ($req) use ($response) { diff --git a/tests/Feature/Mcp/Tools/DatabaseConnectionsTest.php b/tests/Feature/Mcp/Tools/DatabaseConnectionsTest.php index 7a0bb8dd..ba6372e0 100644 --- a/tests/Feature/Mcp/Tools/DatabaseConnectionsTest.php +++ b/tests/Feature/Mcp/Tools/DatabaseConnectionsTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Laravel\Boost\Mcp\Tools\DatabaseConnections; +use Laravel\Mcp\Request; beforeEach(function () { config()->set('database.default', 'mysql'); @@ -15,9 +16,9 @@ test('it returns database connections', function () { $tool = new DatabaseConnections; - $result = $tool->handle([]); + $response = $tool->handle(new Request([])); - expect($result)->isToolResult() + expect($response)->isToolResult() ->toolHasNoError() ->toolJsonContentToMatchArray([ 'default_connection' => 'mysql', @@ -29,9 +30,9 @@ config()->set('database.connections', []); $tool = new DatabaseConnections; - $result = $tool->handle([]); + $response = $tool->handle(new Request([])); - expect($result)->isToolResult() + expect($response)->isToolResult() ->toolHasNoError() ->toolJsonContentToMatchArray([ 'default_connection' => 'mysql', diff --git a/tests/Feature/Mcp/Tools/DatabaseSchemaTest.php b/tests/Feature/Mcp/Tools/DatabaseSchemaTest.php index f7397876..53dd97fd 100644 --- a/tests/Feature/Mcp/Tools/DatabaseSchemaTest.php +++ b/tests/Feature/Mcp/Tools/DatabaseSchemaTest.php @@ -6,6 +6,7 @@ use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Schema; use Laravel\Boost\Mcp\Tools\DatabaseSchema; +use Laravel\Mcp\Request; beforeEach(function () { // Switch the default connection to a file-backed SQLite DB. @@ -38,7 +39,7 @@ test('it returns structured database schema', function () { $tool = new DatabaseSchema; - $response = $tool->handle([]); + $response = $tool->handle(new Request([])); expect($response)->isToolResult() ->toolHasNoError() @@ -70,7 +71,7 @@ $tool = new DatabaseSchema; // Test filtering for 'example' - $response = $tool->handle(['filter' => 'example']); + $response = $tool->handle(new Request(['filter' => 'example'])); expect($response)->isToolResult() ->toolHasNoError() ->toolJsonContent(function ($schemaArray) { @@ -79,7 +80,7 @@ }); // Test filtering for 'user' - $response = $tool->handle(['filter' => 'user']); + $response = $tool->handle(new Request(['filter' => 'user'])); expect($response)->isToolResult() ->toolHasNoError() ->toolJsonContent(function ($schemaArray) { diff --git a/tests/Feature/Mcp/Tools/GetAbsoluteUrlTest.php b/tests/Feature/Mcp/Tools/GetAbsoluteUrlTest.php index e343b8d1..652081dc 100644 --- a/tests/Feature/Mcp/Tools/GetAbsoluteUrlTest.php +++ b/tests/Feature/Mcp/Tools/GetAbsoluteUrlTest.php @@ -4,6 +4,7 @@ use Illuminate\Support\Facades\Route; use Laravel\Boost\Mcp\Tools\GetAbsoluteUrl; +use Laravel\Mcp\Request; beforeEach(function () { config()->set('app.url', 'http://localhost'); @@ -14,45 +15,45 @@ test('it returns absolute url for root path by default', function () { $tool = new GetAbsoluteUrl; - $result = $tool->handle([]); + $response = $tool->handle(new Request([])); - expect($result)->isToolResult() + expect($response)->isToolResult() ->toolHasNoError() ->toolTextContains('http://localhost'); }); test('it returns absolute url for given path', function () { $tool = new GetAbsoluteUrl; - $result = $tool->handle(['path' => '/dashboard']); + $response = $tool->handle(new Request(['path' => '/dashboard'])); - expect($result)->isToolResult() + expect($response)->isToolResult() ->toolHasNoError() ->toolTextContains('http://localhost/dashboard'); }); test('it returns absolute url for named route', function () { $tool = new GetAbsoluteUrl; - $result = $tool->handle(['route' => 'test.route']); + $response = $tool->handle(new Request(['route' => 'test.route'])); - expect($result)->isToolResult() + expect($response)->isToolResult() ->toolHasNoError() ->toolTextContains('http://localhost/test'); }); test('it prioritizes path over route when both are provided', function () { $tool = new GetAbsoluteUrl; - $result = $tool->handle(['path' => '/dashboard', 'route' => 'test.route']); + $response = $tool->handle(new Request(['path' => '/dashboard', 'route' => 'test.route'])); - expect($result)->isToolResult() + expect($response)->isToolResult() ->toolHasNoError() ->toolTextContains('http://localhost/dashboard'); }); test('it handles empty path', function () { $tool = new GetAbsoluteUrl; - $result = $tool->handle(['path' => '']); + $response = $tool->handle(new Request(['path' => ''])); - expect($result)->isToolResult() + expect($response)->isToolResult() ->toolHasNoError() ->toolTextContains('http://localhost'); }); diff --git a/tests/Feature/Mcp/Tools/GetConfigTest.php b/tests/Feature/Mcp/Tools/GetConfigTest.php index fd0af9d4..29d2de81 100644 --- a/tests/Feature/Mcp/Tools/GetConfigTest.php +++ b/tests/Feature/Mcp/Tools/GetConfigTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Laravel\Boost\Mcp\Tools\GetConfig; +use Laravel\Mcp\Request; beforeEach(function () { config()->set('test.key', 'test_value'); @@ -12,36 +13,36 @@ test('it returns config value when key exists', function () { $tool = new GetConfig; - $result = $tool->handle(['key' => 'test.key']); + $response = $tool->handle(new Request(['key' => 'test.key'])); - expect($result)->isToolResult() + expect($response)->isToolResult() ->toolHasNoError() ->toolTextContains('"key": "test.key"', '"value": "test_value"'); }); test('it returns nested config value', function () { $tool = new GetConfig; - $result = $tool->handle(['key' => 'nested.config.key']); + $response = $tool->handle(new Request(['key' => 'nested.config.key'])); - expect($result)->isToolResult() + expect($response)->isToolResult() ->toolHasNoError() ->toolTextContains('"key": "nested.config.key"', '"value": "nested_value"'); }); test('it returns error when config key does not exist', function () { $tool = new GetConfig; - $result = $tool->handle(['key' => 'nonexistent.key']); + $response = $tool->handle(new Request(['key' => 'nonexistent.key'])); - expect($result)->isToolResult() + expect($response)->isToolResult() ->toolHasError() ->toolTextContains("Config key 'nonexistent.key' not found."); }); test('it works with built-in Laravel config keys', function () { $tool = new GetConfig; - $result = $tool->handle(['key' => 'app.name']); + $response = $tool->handle(new Request(['key' => 'app.name'])); - expect($result)->isToolResult() + expect($response)->isToolResult() ->toolHasNoError() ->toolTextContains('"key": "app.name"', '"value": "Test App"'); }); diff --git a/tests/Feature/Mcp/Tools/ListArtisanCommandsTest.php b/tests/Feature/Mcp/Tools/ListArtisanCommandsTest.php index f71b2370..8723bebd 100644 --- a/tests/Feature/Mcp/Tools/ListArtisanCommandsTest.php +++ b/tests/Feature/Mcp/Tools/ListArtisanCommandsTest.php @@ -3,12 +3,13 @@ declare(strict_types=1); use Laravel\Boost\Mcp\Tools\ListArtisanCommands; +use Laravel\Mcp\Request; test('it returns list of artisan commands', function () { $tool = new ListArtisanCommands; - $result = $tool->handle([]); + $response = $tool->handle(new Request([])); - expect($result)->isToolResult() + expect($response)->isToolResult() ->toolHasNoError() ->toolJsonContent(function ($content) { expect($content)->toBeArray() diff --git a/tests/Feature/Mcp/Tools/ListAvailableConfigKeysTest.php b/tests/Feature/Mcp/Tools/ListAvailableConfigKeysTest.php index 697c42f0..c2d8d062 100644 --- a/tests/Feature/Mcp/Tools/ListAvailableConfigKeysTest.php +++ b/tests/Feature/Mcp/Tools/ListAvailableConfigKeysTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Laravel\Boost\Mcp\Tools\ListAvailableConfigKeys; +use Laravel\Mcp\Request; beforeEach(function () { config()->set('test.simple', 'value'); @@ -12,9 +13,9 @@ test('it returns list of config keys in dot notation', function () { $tool = new ListAvailableConfigKeys; - $result = $tool->handle([]); + $response = $tool->handle(new Request([])); - expect($result)->isToolResult() + expect($response)->isToolResult() ->toolHasNoError() ->toolJsonContent(function ($content) { expect($content)->toBeArray() @@ -41,9 +42,9 @@ config()->set('test', null); $tool = new ListAvailableConfigKeys; - $result = $tool->handle([]); + $response = $tool->handle(new Request([])); - expect($result)->isToolResult() + expect($response)->isToolResult() ->toolHasNoError() ->toolJsonContent(function ($content) { expect($content)->toBeArray() diff --git a/tests/Feature/Mcp/Tools/ListRoutesTest.php b/tests/Feature/Mcp/Tools/ListRoutesTest.php index df1ddfbc..7f8e3602 100644 --- a/tests/Feature/Mcp/Tools/ListRoutesTest.php +++ b/tests/Feature/Mcp/Tools/ListRoutesTest.php @@ -4,6 +4,7 @@ use Illuminate\Support\Facades\Route; use Laravel\Boost\Mcp\Tools\ListRoutes; +use Laravel\Mcp\Request; beforeEach(function () { Route::get('/admin/dashboard', function () { @@ -33,9 +34,9 @@ test('it returns list of routes without filters', function () { $tool = new ListRoutes; - $result = $tool->handle([]); + $response = $tool->handle(new Request([])); - expect($result)->isToolResult() + expect($response)->isToolResult() ->toolHasNoError() ->toolTextContains('GET|HEAD', 'admin.dashboard', 'user.profile'); }); @@ -43,90 +44,90 @@ test('it sanitizes name parameter wildcards and filters correctly', function () { $tool = new ListRoutes; - $result = $tool->handle(['name' => '*admin*']); + $response = $tool->handle(new Request(['name' => '*admin*'])); - expect($result)->isToolResult() + expect($response)->isToolResult() ->toolHasNoError() ->toolTextContains('admin.dashboard', 'admin.users.store') - ->and($result)->not->toolTextContains('user.profile', 'two-factor.enable'); + ->and($response)->not->toolTextContains('user.profile', 'two-factor.enable'); - $result = $tool->handle(['name' => '*two-factor*']); + $response = $tool->handle(new Request(['name' => '*two-factor*'])); - expect($result)->toolTextContains('two-factor.enable') - ->and($result)->not->toolTextContains('admin.dashboard', 'user.profile'); + expect($response)->toolTextContains('two-factor.enable') + ->and($response)->not->toolTextContains('admin.dashboard', 'user.profile'); - $result = $tool->handle(['name' => '*api*']); + $response = $tool->handle(new Request(['name' => '*api*'])); - expect($result)->toolTextContains('api.posts.index', 'api.posts.update') - ->and($result)->not->toolTextContains('admin.dashboard', 'user.profile'); + expect($response)->toolTextContains('api.posts.index', 'api.posts.update') + ->and($response)->not->toolTextContains('admin.dashboard', 'user.profile'); }); test('it sanitizes method parameter wildcards and filters correctly', function () { $tool = new ListRoutes; - $result = $tool->handle(['method' => 'GET*POST']); + $response = $tool->handle(new Request(['method' => 'GET*POST'])); - expect($result)->isToolResult() + expect($response)->isToolResult() ->toolHasNoError() ->toolTextContains('ERROR Your application doesn\'t have any routes matching the given criteria.'); - $result = $tool->handle(['method' => '*GET*']); + $response = $tool->handle(new Request(['method' => '*GET*'])); - expect($result)->toolTextContains('admin.dashboard', 'user.profile', 'api.posts.index') - ->and($result)->not->toolTextContains('admin.users.store'); + expect($response)->toolTextContains('admin.dashboard', 'user.profile', 'api.posts.index') + ->and($response)->not->toolTextContains('admin.users.store'); - $result = $tool->handle(['method' => '*POST*']); + $response = $tool->handle(new Request(['method' => '*POST*'])); - expect($result)->toolTextContains('admin.users.store') - ->and($result)->not->toolTextContains('admin.dashboard'); + expect($response)->toolTextContains('admin.users.store') + ->and($response)->not->toolTextContains('admin.dashboard'); }); test('it handles edge cases and empty results correctly', function () { $tool = new ListRoutes; - $result = $tool->handle(['name' => '*']); + $response = $tool->handle(new Request(['name' => '*'])); - expect($result)->isToolResult() + expect($response)->isToolResult() ->toolHasNoError() ->toolTextContains('admin.dashboard', 'user.profile', 'two-factor.enable'); - $result = $tool->handle(['name' => '*nonexistent*']); + $response = $tool->handle(new Request(['name' => '*nonexistent*'])); - expect($result)->toolTextContains('ERROR Your application doesn\'t have any routes matching the given criteria.'); + expect($response)->toolTextContains('ERROR Your application doesn\'t have any routes matching the given criteria.'); - $result = $tool->handle(['name' => '']); + $response = $tool->handle(new Request(['name' => ''])); - expect($result)->toolTextContains('admin.dashboard', 'user.profile'); + expect($response)->toolTextContains('admin.dashboard', 'user.profile'); }); test('it handles multiple parameters with wildcard sanitization', function () { $tool = new ListRoutes; - $result = $tool->handle([ + $response = $tool->handle(new Request([ 'name' => '*admin*', 'method' => '*GET*', - ]); + ])); - expect($result)->isToolResult() + expect($response)->isToolResult() ->toolHasNoError() ->toolTextContains('admin.dashboard') - ->and($result)->not->toolTextContains('admin.users.store', 'user.profile'); + ->and($response)->not->toolTextContains('admin.users.store', 'user.profile'); - $result = $tool->handle([ + $response = $tool->handle(new Request([ 'name' => '*user*', 'method' => '*POST*', - ]); + ])); - expect($result)->toolTextContains('admin.users.store'); + expect($response)->toolTextContains('admin.users.store'); }); test('it handles the original problematic wildcard case', function () { $tool = new ListRoutes; - $result = $tool->handle(['name' => '*two-factor*']); + $response = $tool->handle(new Request(['name' => '*two-factor*'])); - expect($result)->isToolResult() + expect($response)->isToolResult() ->toolHasNoError() ->toolTextContains('two-factor.enable'); }); diff --git a/tests/Feature/Mcp/Tools/SearchDocsTest.php b/tests/Feature/Mcp/Tools/SearchDocsTest.php index 2fadc220..67517474 100644 --- a/tests/Feature/Mcp/Tools/SearchDocsTest.php +++ b/tests/Feature/Mcp/Tools/SearchDocsTest.php @@ -4,6 +4,7 @@ use Illuminate\Support\Facades\Http; use Laravel\Boost\Mcp\Tools\SearchDocs; +use Laravel\Mcp\Request; use Laravel\Roster\Enums\Packages; use Laravel\Roster\Package; use Laravel\Roster\PackageCollection; @@ -23,9 +24,9 @@ ]); $tool = new SearchDocs($roster); - $result = $tool->handle(['queries' => ['authentication', 'testing']]); + $response = $tool->handle(new Request(['queries' => ['authentication', 'testing']])); - expect($result)->isToolResult() + expect($response)->isToolResult() ->toolHasNoError() ->toolTextContains('Documentation search results'); @@ -54,9 +55,9 @@ ]); $tool = new SearchDocs($roster); - $result = $tool->handle(['queries' => ['authentication']]); + $response = $tool->handle(new Request(['queries' => ['authentication']])); - expect($result)->isToolResult() + expect($response)->isToolResult() ->toolHasError() ->toolTextContains('Failed to search documentation: API Error'); }); @@ -72,9 +73,9 @@ ]); $tool = new SearchDocs($roster); - $result = $tool->handle(['queries' => ['test', ' ', '*', ' ']]); + $response = $tool->handle(new Request(['queries' => ['test', ' ', '*', ' ']])); - expect($result)->isToolResult() + expect($response)->isToolResult() ->toolHasNoError(); Http::assertSent(function ($request) { @@ -99,9 +100,9 @@ ]); $tool = new SearchDocs($roster); - $result = $tool->handle(['queries' => ['test']]); + $response = $tool->handle(new Request(['queries' => ['test']])); - expect($result)->isToolResult() + expect($response)->isToolResult() ->toolHasNoError(); Http::assertSent(function ($request) { @@ -123,9 +124,9 @@ ]); $tool = new SearchDocs($roster); - $result = $tool->handle(['queries' => ['nonexistent']]); + $response = $tool->handle(new Request(['queries' => ['nonexistent']])); - expect($result)->isToolResult() + expect($response)->isToolResult() ->toolHasNoError() ->toolTextContains('Empty response'); }); @@ -141,9 +142,9 @@ ]); $tool = new SearchDocs($roster); - $result = $tool->handle(['queries' => ['test'], 'token_limit' => 5000]); + $response = $tool->handle(new Request(['queries' => ['test'], 'token_limit' => 5000])); - expect($result)->isToolResult()->toolHasNoError(); + expect($response)->isToolResult()->toolHasNoError(); Http::assertSent(function ($request) { return $request->data()['token_limit'] === 5000; @@ -161,9 +162,9 @@ ]); $tool = new SearchDocs($roster); - $result = $tool->handle(['queries' => ['test'], 'token_limit' => 2000000]); + $response = $tool->handle(new Request(['queries' => ['test'], 'token_limit' => 2000000])); - expect($result)->isToolResult()->toolHasNoError(); + expect($response)->isToolResult()->toolHasNoError(); Http::assertSent(function ($request) { return $request->data()['token_limit'] === 1000000; diff --git a/tests/Feature/Mcp/Tools/TinkerTest.php b/tests/Feature/Mcp/Tools/TinkerTest.php index 8444067f..f3f5dfbb 100644 --- a/tests/Feature/Mcp/Tools/TinkerTest.php +++ b/tests/Feature/Mcp/Tools/TinkerTest.php @@ -3,12 +3,13 @@ declare(strict_types=1); use Laravel\Boost\Mcp\Tools\Tinker; +use Laravel\Mcp\Request; test('executes simple php code', function () { $tool = new Tinker; - $result = $tool->handle(['code' => 'return 2 + 2;']); + $response = $tool->handle(new Request(['code' => 'return 2 + 2;'])); - expect($result)->isToolResult() + expect($response)->isToolResult() ->toolJsonContentToMatchArray([ 'result' => 4, 'type' => 'integer', @@ -17,9 +18,9 @@ test('executes code with output', function () { $tool = new Tinker; - $result = $tool->handle(['code' => 'echo "Hello World"; return "test";']); + $response = $tool->handle(new Request(['code' => 'echo "Hello World"; return "test";'])); - expect($result)->isToolResult() + expect($response)->isToolResult() ->toolJsonContentToMatchArray([ 'result' => 'test', 'output' => 'Hello World', @@ -29,9 +30,9 @@ test('accesses laravel facades', function () { $tool = new Tinker; - $result = $tool->handle(['code' => 'return config("app.name");']); + $response = $tool->handle(new Request(['code' => 'return config("app.name");'])); - expect($result)->isToolResult() + expect($response)->isToolResult() ->toolJsonContentToMatchArray([ 'result' => config('app.name'), 'type' => 'string', @@ -40,9 +41,9 @@ test('creates objects', function () { $tool = new Tinker; - $result = $tool->handle(['code' => 'return new stdClass();']); + $response = $tool->handle(new Request(['code' => 'return new stdClass();'])); - expect($result)->isToolResult() + expect($response)->isToolResult() ->toolJsonContentToMatchArray([ 'type' => 'object', 'class' => 'stdClass', @@ -51,9 +52,9 @@ test('handles syntax errors', function () { $tool = new Tinker; - $result = $tool->handle(['code' => 'invalid syntax here']); + $response = $tool->handle(new Request(['code' => 'invalid syntax here'])); - expect($result)->isToolResult() + expect($response)->isToolResult() ->toolHasNoError() ->toolJsonContentToMatchArray([ 'type' => 'ParseError', @@ -65,9 +66,9 @@ test('handles runtime errors', function () { $tool = new Tinker; - $result = $tool->handle(['code' => 'throw new Exception("Test error");']); + $response = $tool->handle(new Request(['code' => 'throw new Exception("Test error");'])); - expect($result)->isToolResult() + expect($response)->isToolResult() ->toolHasNoError() ->toolJsonContentToMatchArray([ 'type' => 'Exception', @@ -80,9 +81,9 @@ test('captures multiple outputs', function () { $tool = new Tinker; - $result = $tool->handle(['code' => 'echo "First"; echo "Second"; return "done";']); + $response = $tool->handle(new Request(['code' => 'echo "First"; echo "Second"; return "done";'])); - expect($result)->isToolResult() + expect($response)->isToolResult() ->toolJsonContentToMatchArray([ 'result' => 'done', 'output' => 'FirstSecond', @@ -91,9 +92,9 @@ test('executes code with different return types', function (string $code, mixed $expectedResult, string $expectedType) { $tool = new Tinker; - $result = $tool->handle(['code' => $code]); + $response = $tool->handle(new Request(['code' => $code])); - expect($result)->isToolResult() + expect($response)->isToolResult() ->toolJsonContentToMatchArray([ 'result' => $expectedResult, 'type' => $expectedType, @@ -110,9 +111,9 @@ test('handles empty code', function () { $tool = new Tinker; - $result = $tool->handle(['code' => '']); + $response = $tool->handle(new Request(['code' => ''])); - expect($result)->isToolResult() + expect($response)->isToolResult() ->toolJsonContentToMatchArray([ 'result' => false, 'type' => 'boolean', @@ -121,9 +122,9 @@ test('handles code with no return statement', function () { $tool = new Tinker; - $result = $tool->handle(['code' => '$x = 5;']); + $response = $tool->handle(new Request(['code' => '$x = 5;'])); - expect($result)->isToolResult() + expect($response)->isToolResult() ->toolJsonContentToMatchArray([ 'result' => null, 'type' => 'NULL', @@ -133,10 +134,9 @@ test('should register only in local environment', function () { $tool = new Tinker; - // Test in local environment app()->detectEnvironment(function () { return 'local'; }); - expect($tool->shouldRegister())->toBeTrue(); + expect($tool->eligibleForRegistration(Mockery::mock(Request::class)))->toBeTrue(); }); diff --git a/tests/Pest.php b/tests/Pest.php index b770b104..14b4b9fa 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -13,55 +13,53 @@ | */ -use Laravel\Mcp\Server\Tools\ToolResult; +use Laravel\Mcp\Response; uses(Tests\TestCase::class)->in('Feature'); expect()->extend('isToolResult', function () { - return $this->toBeInstanceOf(ToolResult::class); + return $this->toBeInstanceOf(Response::class); }); expect()->extend('toolTextContains', function (mixed ...$needles) { - /** @var ToolResult $this->value */ - $output = implode('', array_column($this->value->toArray()['content'], 'text')); + /** @var Response $this->value */ + $output = (string) $this->value->content(); expect($output)->toContain(...func_get_args()); return $this; }); expect()->extend('toolTextDoesNotContain', function (mixed ...$needles) { - /** @var ToolResult $this->value */ - $output = implode('', array_column($this->value->toArray()['content'], 'text')); + /** @var Response $this->value */ + $output = (string) $this->value->content(); expect($output)->not->toContain(...func_get_args()); return $this; }); expect()->extend('toolHasError', function () { - expect($this->value->toArray()['isError'])->toBeTrue(); + expect($this->value->isError())->toBeTrue(); return $this; }); expect()->extend('toolHasNoError', function () { - expect($this->value->toArray()['isError'])->toBeFalse(); + expect($this->value->isError())->toBeFalse(); return $this; }); expect()->extend('toolJsonContent', function (callable $callback) { - /** @var ToolResult $this->value */ - $data = $this->value->toArray(); - $content = json_decode($data['content'][0]['text'], true); + /** @var Response $this->value */ + $content = json_decode((string) $this->value->content(), true); $callback($content); return $this; }); expect()->extend('toolJsonContentToMatchArray', function (array $expectedArray) { - /** @var ToolResult $this->value */ - $data = $this->value->toArray(); - $content = json_decode($data['content'][0]['text'], true); + /** @var Response $this->value */ + $content = json_decode((string) $this->value->content(), true); expect($content)->toMatchArray($expectedArray); return $this;