From 312e48464dab8ebdfaf3f33bbd4eb652c5f17a29 Mon Sep 17 00:00:00 2001 From: Nuno Maduro Date: Wed, 17 Sep 2025 15:36:06 +0100 Subject: [PATCH 01/17] bumps dependencies --- composer.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/composer.json b/composer.json index 495b4174..026bb584 100644 --- a/composer.json +++ b/composer.json @@ -15,11 +15,11 @@ "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", + "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": "^0.2", "laravel/prompts": "^0.1.9|^0.3", "laravel/roster": "^0.2.5" }, From 6e61fe25beffa60ffad9958dd650787c13ad6813 Mon Sep 17 00:00:00 2001 From: Nuno Maduro Date: Wed, 17 Sep 2025 22:39:40 +0100 Subject: [PATCH 02/17] bumps composer --- composer.json | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/composer.json b/composer.json index 026bb584..6c6d6634 100644 --- a/composer.json +++ b/composer.json @@ -14,21 +14,21 @@ }, "require": { "php": "^8.1", - "guzzlehttp/guzzle": "^7.9", + "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": "^0.2", - "laravel/prompts": "^0.1.9|^0.3", - "laravel/roster": "^0.2.5" + "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": { From 12fbae77a9804e711abf448ed504e31df28b06b5 Mon Sep 17 00:00:00 2001 From: Nuno Maduro Date: Wed, 17 Sep 2025 23:30:57 +0100 Subject: [PATCH 03/17] adds work in progress --- src/BoostServiceProvider.php | 2 +- src/Console/ExecuteToolCommand.php | 19 +++-- src/Mcp/Boost.php | 80 +++++++++++++------ src/Mcp/Methods/CallToolWithExecutor.php | 67 ++++++++-------- src/Mcp/Resources/ApplicationInfo.php | 49 +++++++----- src/Mcp/ToolExecutor.php | 43 ++++++---- src/Mcp/Tools/ApplicationInfo.php | 28 ++++--- src/Mcp/Tools/BrowserLogs.php | 46 ++++++----- src/Mcp/Tools/DatabaseConnections.php | 28 ++++--- src/Mcp/Tools/DatabaseQuery.php | 51 ++++++------ src/Mcp/Tools/DatabaseSchema.php | 45 ++++++----- src/Mcp/Tools/GetAbsoluteUrl.php | 51 ++++++------ src/Mcp/Tools/GetConfig.php | 27 ++++--- src/Mcp/Tools/LastError.php | 34 ++++---- src/Mcp/Tools/ListArtisanCommands.php | 28 ++++--- src/Mcp/Tools/ListAvailableConfigKeys.php | 28 ++++--- src/Mcp/Tools/ListAvailableEnvVars.php | 49 +++++++----- src/Mcp/Tools/ListRoutes.php | 57 +++++++------ src/Mcp/Tools/ReadLogEntries.php | 46 ++++++----- src/Mcp/Tools/ReportFeedback.php | 29 ++++--- src/Mcp/Tools/SearchDocs.php | 70 ++++++++-------- src/Mcp/Tools/Tinker.php | 47 ++++++----- tests/Feature/Mcp/ToolExecutorTest.php | 55 +++++++------ .../Feature/Mcp/Tools/ApplicationInfoTest.php | 9 ++- tests/Feature/Mcp/Tools/BrowserLogsTest.php | 35 ++++---- .../Mcp/Tools/DatabaseConnectionsTest.php | 9 ++- .../Feature/Mcp/Tools/DatabaseSchemaTest.php | 7 +- .../Feature/Mcp/Tools/GetAbsoluteUrlTest.php | 21 ++--- tests/Feature/Mcp/Tools/GetConfigTest.php | 17 ++-- .../Mcp/Tools/ListArtisanCommandsTest.php | 5 +- .../Mcp/Tools/ListAvailableConfigKeysTest.php | 9 ++- tests/Feature/Mcp/Tools/ListRoutesTest.php | 69 ++++++++-------- tests/Feature/Mcp/Tools/SearchDocsTest.php | 29 +++---- tests/Feature/Mcp/Tools/TinkerTest.php | 44 +++++----- tests/Pest.php | 26 +++--- 35 files changed, 690 insertions(+), 569 deletions(-) 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..d7d75b70 100644 --- a/src/Console/ExecuteToolCommand.php +++ b/src/Console/ExecuteToolCommand.php @@ -6,7 +6,8 @@ use Illuminate\Console\Command; use Laravel\Boost\Mcp\ToolRegistry; -use Laravel\Mcp\Server\Tools\ToolResult; +use Laravel\Mcp\Request; +use Laravel\Mcp\Response; class ExecuteToolCommand extends Command { @@ -39,17 +40,25 @@ public function handle(): int try { // Execute the tool $tool = app($toolClass); - $result = $tool->handle($arguments ?? []); + + $request = new Request($arguments ?? []); + $response = $tool->handle($request); // Output the result as JSON for the parent process - echo json_encode($result->toArray()); + echo json_encode([ + 'isError' => $response->isError(), + 'content' => (string) $response->content(), + ]); return 0; } catch (\Throwable $e) { // Output error result - $errorResult = ToolResult::error("Tool execution failed (E_THROWABLE): {$e->getMessage()}"); - $this->error(json_encode($errorResult->toArray())); + $errorResult = Response::error("Tool execution failed (E_THROWABLE): {$e->getMessage()}"); + $this->error(json_encode([ + 'isError' => true, + 'content' => (string) $errorResult->content(), + ])); return 1; } diff --git a/src/Mcp/Boost.php b/src/Mcp/Boost.php index fb0fb5f3..9d066d55 100644 --- a/src/Mcp/Boost.php +++ b/src/Mcp/Boost.php @@ -10,43 +10,69 @@ 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.'; - public int $defaultPaginationLength = 50; + /** + * The tools registered with this MCP server. + * + * @var array> + */ + protected array $tools = []; /** - * @var string[] + * 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 +80,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 +109,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 +138,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..0ffff9fd 100644 --- a/src/Mcp/Methods/CallToolWithExecutor.php +++ b/src/Mcp/Methods/CallToolWithExecutor.php @@ -4,57 +4,54 @@ namespace Laravel\Boost\Mcp\Methods; -use Illuminate\Support\ItemNotFoundException; use Laravel\Boost\Mcp\ToolExecutor; -use Laravel\Mcp\Server\Contracts\Methods\Method; +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; + /** * 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']; - } - - $result = $executor->execute(get_class($tool), $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, + )); + + $executor = app(ToolExecutor::class); + + $arguments = []; + if (isset($request->params['arguments']) && is_array($request->params['arguments'])) { + $arguments = $request->params['arguments']; + } - return JsonRpcResponse::create($request->id, $result); + $response = $executor->execute(get_class($tool), $arguments); - } catch (Throwable $e) { - return JsonRpcResponse::create( - $request->id, - ToolResult::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..17119bc7 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(Request $request): 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..0bf42b5c 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); } 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,17 +76,22 @@ 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 reconstructToolResult(array $data): Response { if (! isset($data['isError']) || ! isset($data['content'])) { - return ToolResult::error('Invalid tool result format'); + return Response::error('Invalid tool result format'); } if ($data['isError']) { + // Content can be either a string or array format + if (is_string($data['content'])) { + return Response::error($data['content']); + } + // Extract the actual text content from the content array $errorText = 'Unknown error'; if (is_array($data['content']) && ! empty($data['content'])) { @@ -95,27 +101,32 @@ protected function reconstructToolResult(array $data): ToolResult } } - return ToolResult::error($errorText); + return Response::error($errorText); + } + + // Handle successful responses + // Content can be either a string (from ExecuteToolCommand) or array format + if (is_string($data['content'])) { + return Response::text($data['content']); } - // 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..1643a705 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,27 @@ 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 + /** + * Get the tool's input schema. + * + * @return array + */ + public function schema(JsonSchema $schema): array { - return $schema; + return []; } /** - * @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..f9900292 100644 --- a/src/Mcp/Tools/DatabaseConnections.php +++ b/src/Mcp/Tools/DatabaseConnections.php @@ -4,32 +4,38 @@ 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 + /** + * Get the tool's input schema. + * + * @return array + */ + public function schema(JsonSchema $schema): array { - return $schema; + return []; } /** - * @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..7c6ebc6a 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,18 @@ 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; + // Get connection name, converting to string or null + $connectionName = $request->has('database') ? (string) $request->string('database') : null; 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..a024868b 100644 --- a/src/Mcp/Tools/GetAbsoluteUrl.php +++ b/src/Mcp/Tools/GetAbsoluteUrl.php @@ -4,49 +4,52 @@ 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'); + // Get the values and cast to string or null + $path = $request->has('path') ? (string) $request->string('path') : null; + $routeName = $request->has('route') ? (string) $request->string('route') : null; 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..113f34cd 100644 --- a/src/Mcp/Tools/GetConfig.php +++ b/src/Mcp/Tools/GetConfig.php @@ -4,11 +4,12 @@ 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 @@ -18,26 +19,28 @@ public function description(): string return '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 []; } /** - * @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..7caa3080 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,25 @@ 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 + /** + * Get the tool's input schema. + * + * @return array + */ + public function schema(JsonSchema $schema): array { - return $schema; + return []; } /** - * @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 +72,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..e1603f2e 100644 --- a/src/Mcp/Tools/ListArtisanCommands.php +++ b/src/Mcp/Tools/ListArtisanCommands.php @@ -5,29 +5,35 @@ 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 + /** + * Get the tool's input schema. + * + * @return array + */ + public function schema(JsonSchema $schema): array { - return $schema; + return []; } /** - * @param array $arguments + * Handle the tool request. */ - public function handle(array $arguments): ToolResult + public function handle(Request $request): Response { $commands = Artisan::all(); @@ -43,6 +49,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..3d2cc60d 100644 --- a/src/Mcp/Tools/ListAvailableConfigKeys.php +++ b/src/Mcp/Tools/ListAvailableConfigKeys.php @@ -4,35 +4,41 @@ 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 + /** + * Get the tool's input schema. + * + * @return array + */ + public function schema(JsonSchema $schema): array { - return $schema; + return []; } /** - * @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..5492038d 100644 --- a/src/Mcp/Tools/ListAvailableEnvVars.php +++ b/src/Mcp/Tools/ListAvailableEnvVars.php @@ -4,62 +4,69 @@ 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->has('filename') ? (string) $request->string('filename') : '.env'; + if (empty($filename)) { + $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..4bb17771 100644 --- a/src/Mcp/Tools/ReportFeedback.php +++ b/src/Mcp/Tools/ReportFeedback.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; class ReportFeedback extends Tool { @@ -19,24 +20,26 @@ 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.'; } - 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 []; } /** - * @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 +47,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..b1cf0b78 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('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('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->has('packages') ? $request->get('packages') : null; $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..cf62de74 100644 --- a/src/Mcp/Tools/Tinker.php +++ b/src/Mcp/Tools/Tinker.php @@ -5,42 +5,41 @@ 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') + ->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->string('code')); ini_set('memory_limit', '256M'); @@ -62,9 +61,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; From a691ca17ebeff0590fce30190d1e8dd2194feb2e Mon Sep 17 00:00:00 2001 From: Nuno Maduro Date: Thu, 18 Sep 2025 10:27:26 +0100 Subject: [PATCH 04/17] fix command --- src/Console/ExecuteToolCommand.php | 40 +++++++++++++++++------------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/src/Console/ExecuteToolCommand.php b/src/Console/ExecuteToolCommand.php index d7d75b70..517e1015 100644 --- a/src/Console/ExecuteToolCommand.php +++ b/src/Console/ExecuteToolCommand.php @@ -8,6 +8,8 @@ use Laravel\Boost\Mcp\ToolRegistry; use Laravel\Mcp\Request; use Laravel\Mcp\Response; +use Laravel\Mcp\Server\Tool; +use Throwable; class ExecuteToolCommand extends Command { @@ -37,30 +39,34 @@ public function handle(): int return 1; } - try { - // Execute the tool - $tool = app($toolClass); - - $request = new Request($arguments ?? []); - $response = $tool->handle($request); + /** @var Tool $tool */ + $tool = app($toolClass); - // Output the result as JSON for the parent process - echo json_encode([ - 'isError' => $response->isError(), - 'content' => (string) $response->content(), - ]); + $request = new Request($arguments ?? []); - return 0; - - } catch (\Throwable $e) { - // Output error result + try { + /** @var Response $response */ + $response = $tool->handle($request); + } catch (Throwable $e) { $errorResult = Response::error("Tool execution failed (E_THROWABLE): {$e->getMessage()}"); + $this->error(json_encode([ 'isError' => true, - 'content' => (string) $errorResult->content(), + 'content' => [ + $errorResult->content()->toTool($tool), + ], ])); - return 1; + return static::FAILURE; } + + echo json_encode([ + 'isError' => $response->isError(), + 'content' => [ + $response->content()->toTool($tool), + ], + ]); + + return static::SUCCESS; } } From d424c2d81089e60bd1f59ec1cadc6413808cecf1 Mon Sep 17 00:00:00 2001 From: Nuno Maduro Date: Thu, 18 Sep 2025 10:33:42 +0100 Subject: [PATCH 05/17] refactor --- src/Mcp/Methods/CallToolWithExecutor.php | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/Mcp/Methods/CallToolWithExecutor.php b/src/Mcp/Methods/CallToolWithExecutor.php index 0ffff9fd..f4f6545b 100644 --- a/src/Mcp/Methods/CallToolWithExecutor.php +++ b/src/Mcp/Methods/CallToolWithExecutor.php @@ -5,6 +5,7 @@ namespace Laravel\Boost\Mcp\Methods; use Laravel\Boost\Mcp\ToolExecutor; +use Laravel\Mcp\Response; use Laravel\Mcp\Server\Contracts\Errable; use Laravel\Mcp\Server\Contracts\Method; use Laravel\Mcp\Server\Exceptions\JsonRpcException; @@ -12,11 +13,17 @@ use Laravel\Mcp\Server\ServerContext; use Laravel\Mcp\Server\Transport\JsonRpcRequest; use Laravel\Mcp\Server\Transport\JsonRpcResponse; +use Throwable; class CallToolWithExecutor implements Method, Errable { use InteractsWithResponses; + public function __construct(protected ToolExecutor $executor) + { + // + } + /** * Handle the JSON-RPC tool/call request with process isolation. */ @@ -40,14 +47,16 @@ public function handle(JsonRpcRequest $request, ServerContext $context): JsonRpc $request->id, )); - $executor = app(ToolExecutor::class); - $arguments = []; if (isset($request->params['arguments']) && is_array($request->params['arguments'])) { $arguments = $request->params['arguments']; } - $response = $executor->execute(get_class($tool), $arguments); + try { + $response = $this->executor->execute(get_class($tool), $arguments); + } catch (Throwable $e) { + $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(), From 498718c19de47173f50e53436cb623ce5cee4236 Mon Sep 17 00:00:00 2001 From: Nuno Maduro Date: Thu, 18 Sep 2025 10:47:18 +0100 Subject: [PATCH 06/17] wip --- src/Mcp/Resources/ApplicationInfo.php | 2 +- src/Mcp/ToolExecutor.php | 13 ++++--------- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/src/Mcp/Resources/ApplicationInfo.php b/src/Mcp/Resources/ApplicationInfo.php index 17119bc7..92ebb493 100644 --- a/src/Mcp/Resources/ApplicationInfo.php +++ b/src/Mcp/Resources/ApplicationInfo.php @@ -34,7 +34,7 @@ public function __construct(protected ToolExecutor $toolExecutor) /** * Handle the resource request. */ - public function handle(Request $request): Response + public function handle(): Response { $response = $this->toolExecutor->execute(ApplicationInfoTool::class); diff --git a/src/Mcp/ToolExecutor.php b/src/Mcp/ToolExecutor.php index 0bf42b5c..c6129ead 100644 --- a/src/Mcp/ToolExecutor.php +++ b/src/Mcp/ToolExecutor.php @@ -55,7 +55,7 @@ protected function executeInSubprocess(string $toolClass, array $arguments): Res 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(); @@ -80,20 +80,15 @@ protected function getTimeout(array $arguments): int * * @param array $data */ - protected function reconstructToolResult(array $data): Response + protected function reconstructResponse(array $data): Response { if (! isset($data['isError']) || ! isset($data['content'])) { - return Response::error('Invalid tool result format'); + return Response::error('Invalid tool response format.'); } if ($data['isError']) { - // Content can be either a string or array format - if (is_string($data['content'])) { - return Response::error($data['content']); - } - - // 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)) { From 5cfca10fda6e10590cb539b05e3d01301a815c5e Mon Sep 17 00:00:00 2001 From: Nuno Maduro Date: Thu, 18 Sep 2025 11:46:22 +0100 Subject: [PATCH 07/17] removes code --- src/Mcp/ToolExecutor.php | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/Mcp/ToolExecutor.php b/src/Mcp/ToolExecutor.php index c6129ead..f68df218 100644 --- a/src/Mcp/ToolExecutor.php +++ b/src/Mcp/ToolExecutor.php @@ -99,12 +99,6 @@ protected function reconstructResponse(array $data): Response return Response::error($errorText); } - // Handle successful responses - // Content can be either a string (from ExecuteToolCommand) or array format - if (is_string($data['content'])) { - return Response::text($data['content']); - } - // Handle array format - extract text content if (is_array($data['content']) && ! empty($data['content'])) { $firstContent = $data['content'][0] ?? []; From bd5572cebccf4a1ff61b1e71a36025ce2443ad3e Mon Sep 17 00:00:00 2001 From: Nuno Maduro Date: Thu, 18 Sep 2025 12:22:33 +0100 Subject: [PATCH 08/17] wip --- src/Mcp/Tools/DatabaseQuery.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Mcp/Tools/DatabaseQuery.php b/src/Mcp/Tools/DatabaseQuery.php index 7c6ebc6a..886046ce 100644 --- a/src/Mcp/Tools/DatabaseQuery.php +++ b/src/Mcp/Tools/DatabaseQuery.php @@ -73,7 +73,6 @@ public function handle(Request $request): Response return Response::error('Only read-only queries are allowed (SELECT, SHOW, EXPLAIN, DESCRIBE, DESC, WITH … SELECT).'); } - // Get connection name, converting to string or null $connectionName = $request->has('database') ? (string) $request->string('database') : null; try { From ab51f099d8cbe08df734de0fedd416e74b8e0a84 Mon Sep 17 00:00:00 2001 From: Nuno Maduro Date: Thu, 18 Sep 2025 12:25:26 +0100 Subject: [PATCH 09/17] wip --- src/Mcp/Tools/GetConfig.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Mcp/Tools/GetConfig.php b/src/Mcp/Tools/GetConfig.php index 113f34cd..3ec46c3d 100644 --- a/src/Mcp/Tools/GetConfig.php +++ b/src/Mcp/Tools/GetConfig.php @@ -26,7 +26,12 @@ public function description(): string */ public function schema(JsonSchema $schema): array { - return []; + return [ + $schema + ->string('key') + ->description('The config key in dot notation (e.g., "app.name", "database.default")') + ->required(), + ]; } /** From cb4df86f285d42f209f75195a8d9485fb28eaaa9 Mon Sep 17 00:00:00 2001 From: Nuno Maduro Date: Thu, 18 Sep 2025 12:36:38 +0100 Subject: [PATCH 10/17] wip --- src/Console/ExecuteToolCommand.php | 2 +- src/Mcp/Tools/GetConfig.php | 11 ++++------- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/src/Console/ExecuteToolCommand.php b/src/Console/ExecuteToolCommand.php index 517e1015..28478b5c 100644 --- a/src/Console/ExecuteToolCommand.php +++ b/src/Console/ExecuteToolCommand.php @@ -46,7 +46,7 @@ public function handle(): int try { /** @var Response $response */ - $response = $tool->handle($request); + $response = $tool->handle($request); // @phpstan-ignore-line } catch (Throwable $e) { $errorResult = Response::error("Tool execution failed (E_THROWABLE): {$e->getMessage()}"); diff --git a/src/Mcp/Tools/GetConfig.php b/src/Mcp/Tools/GetConfig.php index 3ec46c3d..b5bffe29 100644 --- a/src/Mcp/Tools/GetConfig.php +++ b/src/Mcp/Tools/GetConfig.php @@ -14,10 +14,7 @@ #[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")'; /** * Get the tool's input schema. @@ -27,8 +24,8 @@ public function description(): string public function schema(JsonSchema $schema): array { return [ - $schema - ->string('key') + 'key' => $schema + ->string() ->description('The config key in dot notation (e.g., "app.name", "database.default")') ->required(), ]; @@ -39,7 +36,7 @@ public function schema(JsonSchema $schema): array */ public function handle(Request $request): Response { - $key = $request->get('key'); + $key = $request->get('key')->value(); if (! Config::has($key)) { return Response::error("Config key '{$key}' not found."); From 188fbb00905b0a396a0986b26910f76efb3b521f Mon Sep 17 00:00:00 2001 From: Nuno Maduro Date: Thu, 18 Sep 2025 12:37:02 +0100 Subject: [PATCH 11/17] wip --- src/Mcp/Tools/GetConfig.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Mcp/Tools/GetConfig.php b/src/Mcp/Tools/GetConfig.php index b5bffe29..1d3d9c9b 100644 --- a/src/Mcp/Tools/GetConfig.php +++ b/src/Mcp/Tools/GetConfig.php @@ -36,7 +36,7 @@ public function schema(JsonSchema $schema): array */ public function handle(Request $request): Response { - $key = $request->get('key')->value(); + $key = $request->get('key'); if (! Config::has($key)) { return Response::error("Config key '{$key}' not found."); From 323aec140127adcd9f26adb893535938e9441065 Mon Sep 17 00:00:00 2001 From: Nuno Maduro Date: Thu, 18 Sep 2025 12:48:24 +0100 Subject: [PATCH 12/17] refactor gets --- src/Mcp/Tools/DatabaseQuery.php | 2 +- src/Mcp/Tools/GetAbsoluteUrl.php | 5 ++--- src/Mcp/Tools/ListAvailableEnvVars.php | 5 +---- src/Mcp/Tools/SearchDocs.php | 2 +- 4 files changed, 5 insertions(+), 9 deletions(-) diff --git a/src/Mcp/Tools/DatabaseQuery.php b/src/Mcp/Tools/DatabaseQuery.php index 886046ce..93e8eb0a 100644 --- a/src/Mcp/Tools/DatabaseQuery.php +++ b/src/Mcp/Tools/DatabaseQuery.php @@ -73,7 +73,7 @@ public function handle(Request $request): Response return Response::error('Only read-only queries are allowed (SELECT, SHOW, EXPLAIN, DESCRIBE, DESC, WITH … SELECT).'); } - $connectionName = $request->has('database') ? (string) $request->string('database') : null; + $connectionName = $request->get('database'); try { return Response::json( diff --git a/src/Mcp/Tools/GetAbsoluteUrl.php b/src/Mcp/Tools/GetAbsoluteUrl.php index a024868b..300da97d 100644 --- a/src/Mcp/Tools/GetAbsoluteUrl.php +++ b/src/Mcp/Tools/GetAbsoluteUrl.php @@ -38,9 +38,8 @@ public function schema(JsonSchema $schema): array */ public function handle(Request $request): Response { - // Get the values and cast to string or null - $path = $request->has('path') ? (string) $request->string('path') : null; - $routeName = $request->has('route') ? (string) $request->string('route') : null; + $path = $request->get('path'); + $routeName = $request->get('route'); if ($path) { return Response::text(url($path)); diff --git a/src/Mcp/Tools/ListAvailableEnvVars.php b/src/Mcp/Tools/ListAvailableEnvVars.php index 5492038d..183d59d9 100644 --- a/src/Mcp/Tools/ListAvailableEnvVars.php +++ b/src/Mcp/Tools/ListAvailableEnvVars.php @@ -36,10 +36,7 @@ public function schema(JsonSchema $schema): array */ public function handle(Request $request): Response { - $filename = $request->has('filename') ? (string) $request->string('filename') : '.env'; - if (empty($filename)) { - $filename = '.env'; - } + $filename = $request->get('filename', '.env'); $filePath = base_path($filename); diff --git a/src/Mcp/Tools/SearchDocs.php b/src/Mcp/Tools/SearchDocs.php index b1cf0b78..a97bca25 100644 --- a/src/Mcp/Tools/SearchDocs.php +++ b/src/Mcp/Tools/SearchDocs.php @@ -52,7 +52,7 @@ public function schema(JsonSchema $schema): array public function handle(Request $request): Response|Generator { $apiUrl = config('boost.hosted.api_url', 'https://boost.laravel.com').'/api/docs'; - $packagesFilter = $request->has('packages') ? $request->get('packages') : null; + $packagesFilter = $request->get('packages'); $queries = array_filter( array_map('trim', $request->get('queries')), From d3cfc234152e97d285f4e53f7b8b57962a47a3c8 Mon Sep 17 00:00:00 2001 From: Nuno Maduro Date: Thu, 18 Sep 2025 12:56:38 +0100 Subject: [PATCH 13/17] fix --- src/Mcp/Tools/ReportFeedback.php | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/Mcp/Tools/ReportFeedback.php b/src/Mcp/Tools/ReportFeedback.php index 4bb17771..cde60426 100644 --- a/src/Mcp/Tools/ReportFeedback.php +++ b/src/Mcp/Tools/ReportFeedback.php @@ -15,10 +15,7 @@ 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.'; /** * Get the tool's input schema. @@ -27,7 +24,12 @@ public function description(): string */ public function schema(JsonSchema $schema): array { - return []; + 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(), + ]; } /** From c1d3bb1e29cac7566871ee7703822ea14c9b783a Mon Sep 17 00:00:00 2001 From: Nuno Maduro Date: Thu, 18 Sep 2025 13:03:37 +0100 Subject: [PATCH 14/17] adds description --- src/Mcp/Tools/SearchDocs.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Mcp/Tools/SearchDocs.php b/src/Mcp/Tools/SearchDocs.php index a97bca25..3b7ac3bc 100644 --- a/src/Mcp/Tools/SearchDocs.php +++ b/src/Mcp/Tools/SearchDocs.php @@ -35,11 +35,11 @@ public function schema(JsonSchema $schema): array { return [ 'queries' => $schema->array() - ->items($schema->string()) + ->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()) + ->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.'), From 0ec8be4c87a9d71168ea9331121c14b326b01483 Mon Sep 17 00:00:00 2001 From: Nuno Maduro Date: Thu, 18 Sep 2025 13:08:50 +0100 Subject: [PATCH 15/17] fix tinker --- src/Mcp/Tools/Tinker.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Mcp/Tools/Tinker.php b/src/Mcp/Tools/Tinker.php index cf62de74..23c55372 100644 --- a/src/Mcp/Tools/Tinker.php +++ b/src/Mcp/Tools/Tinker.php @@ -27,7 +27,10 @@ public function schema(JsonSchema $schema): array { return [ 'code' => $schema->string() - ->description('PHP code to execute') + ->description('PHP code to execute (without opening required(), + 'timeout' => $schema->integer() + ->description('Maximum execution time in seconds (default: 180)') ->required(), ]; } @@ -39,7 +42,7 @@ public function schema(JsonSchema $schema): array */ public function handle(Request $request): Response { - $code = str_replace([''], '', (string) $request->string('code')); + $code = str_replace([''], '', (string) $request->get('code')); ini_set('memory_limit', '256M'); From 9a0ac4ce92dcdcb6913737109436e3fd0bdf2f16 Mon Sep 17 00:00:00 2001 From: Nuno Maduro Date: Thu, 18 Sep 2025 13:13:11 +0100 Subject: [PATCH 16/17] uses 50 --- src/Mcp/Boost.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Mcp/Boost.php b/src/Mcp/Boost.php index 9d066d55..cc756303 100644 --- a/src/Mcp/Boost.php +++ b/src/Mcp/Boost.php @@ -25,6 +25,11 @@ class Boost extends Server */ 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; + /** * The tools registered with this MCP server. * From c7e8ea93c7842608cfd9469b14548f4db911704a Mon Sep 17 00:00:00 2001 From: Nuno Maduro Date: Thu, 18 Sep 2025 13:18:01 +0100 Subject: [PATCH 17/17] Removes empty schemas --- src/Mcp/Tools/ApplicationInfo.php | 9 --------- src/Mcp/Tools/DatabaseConnections.php | 9 --------- src/Mcp/Tools/LastError.php | 9 --------- src/Mcp/Tools/ListArtisanCommands.php | 9 --------- src/Mcp/Tools/ListAvailableConfigKeys.php | 9 --------- 5 files changed, 45 deletions(-) diff --git a/src/Mcp/Tools/ApplicationInfo.php b/src/Mcp/Tools/ApplicationInfo.php index 1643a705..429a36fd 100644 --- a/src/Mcp/Tools/ApplicationInfo.php +++ b/src/Mcp/Tools/ApplicationInfo.php @@ -25,15 +25,6 @@ public function __construct(protected Roster $roster, protected GuidelineAssist */ 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.'; - /** - * Get the tool's input schema. - * - * @return array - */ - public function schema(JsonSchema $schema): array - { - return []; - } /** * Handle the tool request. diff --git a/src/Mcp/Tools/DatabaseConnections.php b/src/Mcp/Tools/DatabaseConnections.php index f9900292..3687d135 100644 --- a/src/Mcp/Tools/DatabaseConnections.php +++ b/src/Mcp/Tools/DatabaseConnections.php @@ -18,15 +18,6 @@ class DatabaseConnections extends Tool */ protected string $description = 'List the configured database connection names for this application.'; - /** - * Get the tool's input schema. - * - * @return array - */ - public function schema(JsonSchema $schema): array - { - return []; - } /** * Handle the tool request. diff --git a/src/Mcp/Tools/LastError.php b/src/Mcp/Tools/LastError.php index 7caa3080..dc640516 100644 --- a/src/Mcp/Tools/LastError.php +++ b/src/Mcp/Tools/LastError.php @@ -48,15 +48,6 @@ public function __construct() */ protected string $description = 'Get details of the last error/exception created in this application on the backend. Use browser-log tool for browser errors.'; - /** - * Get the tool's input schema. - * - * @return array - */ - public function schema(JsonSchema $schema): array - { - return []; - } /** * Handle the tool request. diff --git a/src/Mcp/Tools/ListArtisanCommands.php b/src/Mcp/Tools/ListArtisanCommands.php index e1603f2e..5483aa45 100644 --- a/src/Mcp/Tools/ListArtisanCommands.php +++ b/src/Mcp/Tools/ListArtisanCommands.php @@ -20,15 +20,6 @@ class ListArtisanCommands extends Tool */ protected string $description = 'List all available Artisan commands registered in this application.'; - /** - * Get the tool's input schema. - * - * @return array - */ - public function schema(JsonSchema $schema): array - { - return []; - } /** * Handle the tool request. diff --git a/src/Mcp/Tools/ListAvailableConfigKeys.php b/src/Mcp/Tools/ListAvailableConfigKeys.php index 3d2cc60d..1ea5e3dd 100644 --- a/src/Mcp/Tools/ListAvailableConfigKeys.php +++ b/src/Mcp/Tools/ListAvailableConfigKeys.php @@ -19,15 +19,6 @@ class ListAvailableConfigKeys extends Tool */ protected string $description = 'List all available Laravel configuration keys (from config/*.php) in dot notation.'; - /** - * Get the tool's input schema. - * - * @return array - */ - public function schema(JsonSchema $schema): array - { - return []; - } /** * Handle the tool request.