diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 62106bb..5b3baf0 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -74,6 +74,13 @@ jobs: tools: composer:v2 coverage: none + - name: Setup SSH Keys + run: | + mkdir -p ~/.ssh + echo "${{ secrets.MCP_DEPLOY_KEY }}" > ~/.ssh/id_rsa + chmod 600 ~/.ssh/id_rsa + ssh-keyscan github.com >> ~/.ssh/known_hosts + - name: Install dependencies run: | composer update --prefer-dist --no-interaction --no-progress --with="illuminate/contracts:^${{ matrix.laravel }}" diff --git a/composer.json b/composer.json index 7558324..64223f8 100644 --- a/composer.json +++ b/composer.json @@ -20,6 +20,7 @@ ], "require": { "php": "^8.1|^8.2", + "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", diff --git a/src/BoostServiceProvider.php b/src/BoostServiceProvider.php index e8fa758..4567695 100644 --- a/src/BoostServiceProvider.php +++ b/src/BoostServiceProvider.php @@ -126,9 +126,6 @@ private function registerRoutes(): void /** * Build a string message for the log based on various input types. Single-dimensional, and multi: * "data": {"message":"Unhandled Promise Rejection","reason":{"name":"TypeError","message":"NetworkError when attempting to fetch resource.","stack":""}}] - * - * @param array $data - * @return string */ private function buildLogMessageFromData(array $data): string { @@ -141,6 +138,7 @@ private function buildLogMessageFromData(array $data): string is_bool($value) => $value ? 'true' : 'false', is_null($value) => 'null', is_object($value) => json_encode($value), + default => $value, }; } diff --git a/src/Concerns/MakesHttpRequests.php b/src/Concerns/MakesHttpRequests.php index 8f9b246..71b8b8e 100644 --- a/src/Concerns/MakesHttpRequests.php +++ b/src/Concerns/MakesHttpRequests.php @@ -16,8 +16,8 @@ public function client(): PendingRequest 'User-Agent' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:140.0) Gecko/20100101 Firefox/140.0 Laravel Boost', ]); - // Disable SSL verification for local development URLs - if (app()->environment('local') || str_contains(config('boost.hosted.api_url', ''), '.test')) { + // Disable SSL verification for local development URLs and testing + if (app()->environment(['local', 'testing']) || str_contains(config('boost.hosted.api_url', ''), '.test')) { $client = $client->withoutVerifying(); } diff --git a/src/Install/Contracts/DetectionStrategy.php b/src/Install/Contracts/DetectionStrategy.php index 4e70f5a..f427ccf 100644 --- a/src/Install/Contracts/DetectionStrategy.php +++ b/src/Install/Contracts/DetectionStrategy.php @@ -11,9 +11,7 @@ interface DetectionStrategy /** * Detect if the application is installed on the machine. * - * @param array{command:string, ?files:array, ?paths:array} $config - * @param ?Platform $platform - * @return bool + * @param array{command?:string, basePath?:string, files?:array, paths?:array} $config */ public function detect(array $config, ?Platform $platform = null): bool; } diff --git a/src/Middleware/InjectBoost.php b/src/Middleware/InjectBoost.php index d82e8cb..b8166e6 100644 --- a/src/Middleware/InjectBoost.php +++ b/src/Middleware/InjectBoost.php @@ -22,7 +22,7 @@ public function handle(Request $request, Closure $next): Response $injectedContent = $this->injectScript($response->getContent()); $response->setContent($injectedContent); - if ($originalView instanceof View) { + if ($originalView instanceof View && property_exists($response, 'original')) { $response->original = $originalView; } } diff --git a/tests/Feature/Mcp/Tools/SearchDocsTest.php b/tests/Feature/Mcp/Tools/SearchDocsTest.php index 451247d..623ce98 100644 --- a/tests/Feature/Mcp/Tools/SearchDocsTest.php +++ b/tests/Feature/Mcp/Tools/SearchDocsTest.php @@ -2,8 +2,7 @@ declare(strict_types=1); -use Illuminate\Http\Client\PendingRequest; -use Illuminate\Http\Client\Response; +use Illuminate\Support\Facades\Http; use Laravel\Boost\Mcp\Tools\SearchDocs; use Laravel\Mcp\Server\Tools\ToolResult; use Laravel\Roster\Enums\Packages; @@ -20,34 +19,29 @@ $roster = Mockery::mock(Roster::class); $roster->shouldReceive('packages')->andReturn($packages); - $mockResponse = Mockery::mock(Response::class); - $mockResponse->shouldReceive('successful')->andReturn(true); - $mockResponse->shouldReceive('json')->andReturn([ - 'results' => [ - ['content' => 'Laravel documentation content'], - ['content' => 'Pest documentation content'], - ], + Http::fake([ + 'https://boost.laravel.com/api/docs' => Http::response('Documentation search results', 200), ]); - $mockClient = Mockery::mock(PendingRequest::class); - $mockClient->shouldReceive('asJson')->andReturnSelf(); - $mockClient->shouldReceive('post')->andReturn($mockResponse); - - $tool = Mockery::mock(SearchDocs::class, [$roster])->makePartial(); - $tool->shouldReceive('client')->andReturn($mockClient); - - $result = $tool->handle(['queries' => 'authentication, testing']); + $tool = new SearchDocs($roster); + $result = $tool->handle(['queries' => 'authentication###testing']); expect($result)->toBeInstanceOf(ToolResult::class); $data = $result->toArray(); - expect($data['isError'])->toBeFalse(); - - $content = json_decode($data['content'][0]['text'], true); - expect($content['knowledge_count'])->toBe(2); - expect($content['knowledge'])->toContain('Laravel documentation content'); - expect($content['knowledge'])->toContain('Pest documentation content'); - expect($content['knowledge'])->toContain('---'); + expect($data['isError'])->toBeFalse() + ->and($data['content'][0]['text'])->toBe('Documentation search results'); + + Http::assertSent(function ($request) { + return $request->url() === 'https://boost.laravel.com/api/docs' && + $request->data()['queries'] === ['authentication', 'testing'] && + $request->data()['packages'] === [ + ['name' => 'laravel/framework', 'version' => '11.x'], + ['name' => 'pestphp/pest', 'version' => '2.x'], + ] && + $request->data()['token_limit'] === 10000 && + $request->data()['format'] === 'markdown'; + }); }); test('it handles API error response', function () { @@ -58,25 +52,18 @@ $roster = Mockery::mock(Roster::class); $roster->shouldReceive('packages')->andReturn($packages); - $mockResponse = Mockery::mock(Response::class); - $mockResponse->shouldReceive('successful')->andReturn(false); - $mockResponse->shouldReceive('status')->andReturn(500); - $mockResponse->shouldReceive('body')->andReturn('API Error'); - - $mockClient = Mockery::mock(PendingRequest::class); - $mockClient->shouldReceive('asJson')->andReturnSelf(); - $mockClient->shouldReceive('post')->andReturn($mockResponse); - - $tool = Mockery::mock(SearchDocs::class, [$roster])->makePartial(); - $tool->shouldReceive('client')->andReturn($mockClient); + Http::fake([ + 'https://boost.laravel.com/api/docs' => Http::response('API Error', 500), + ]); + $tool = new SearchDocs($roster); $result = $tool->handle(['queries' => 'authentication']); expect($result)->toBeInstanceOf(ToolResult::class); $data = $result->toArray(); - expect($data['isError'])->toBeTrue(); - expect($data['content'][0]['text'])->toBe('Failed to search documentation: API Error'); + expect($data['isError'])->toBeTrue() + ->and($data['content'][0]['text'])->toBe('Failed to search documentation: API Error'); }); test('it filters empty queries', function () { @@ -85,28 +72,24 @@ $roster = Mockery::mock(Roster::class); $roster->shouldReceive('packages')->andReturn($packages); - $mockResponse = Mockery::mock(Response::class); - $mockResponse->shouldReceive('successful')->andReturn(true); - $mockResponse->shouldReceive('json')->andReturn(['results' => []]); - - $mockClient = Mockery::mock(PendingRequest::class); - $mockClient->shouldReceive('asJson')->andReturnSelf(); - $mockClient->shouldReceive('post')->withArgs(function ($url, $payload) { - return $url === 'https://boost.laravel.com/api/docs' && - $payload['queries'] === ['test'] && - empty($payload['packages']) && - $payload['token_limit'] === 10000; - })->andReturn($mockResponse); - - $tool = Mockery::mock(SearchDocs::class, [$roster])->makePartial(); - $tool->shouldReceive('client')->andReturn($mockClient); + Http::fake([ + 'https://boost.laravel.com/api/docs' => Http::response('Empty results', 200), + ]); + $tool = new SearchDocs($roster); $result = $tool->handle(['queries' => 'test### ###*### ']); expect($result)->toBeInstanceOf(ToolResult::class); $data = $result->toArray(); expect($data['isError'])->toBeFalse(); + + Http::assertSent(function ($request) { + return $request->url() === 'https://boost.laravel.com/api/docs' && + $request->data()['queries'] === ['test'] && + empty($request->data()['packages']) && + $request->data()['token_limit'] === 10000; + }); }); test('it formats package data correctly', function () { @@ -118,28 +101,21 @@ $roster = Mockery::mock(Roster::class); $roster->shouldReceive('packages')->andReturn($packages); - $mockResponse = Mockery::mock(Response::class); - $mockResponse->shouldReceive('successful')->andReturn(true); - $mockResponse->shouldReceive('json')->andReturn(['results' => []]); - - $mockClient = Mockery::mock(PendingRequest::class); - $mockClient->shouldReceive('asJson')->andReturnSelf(); - $mockClient->shouldReceive('post')->with( - 'https://boost.laravel.com/api/docs', - Mockery::on(function ($payload) { - return $payload['packages'] === [ - ['name' => 'laravel/framework', 'version' => '11.x'], - ['name' => 'livewire/livewire', 'version' => '3.x'], - ] && $payload['token_limit'] === 10000; - }) - )->andReturn($mockResponse); - - $tool = Mockery::mock(SearchDocs::class, [$roster])->makePartial(); - $tool->shouldReceive('client')->andReturn($mockClient); + Http::fake([ + 'https://boost.laravel.com/api/docs' => Http::response('Package data results', 200), + ]); + $tool = new SearchDocs($roster); $result = $tool->handle(['queries' => 'test']); expect($result)->toBeInstanceOf(ToolResult::class); + + Http::assertSent(function ($request) { + return $request->data()['packages'] === [ + ['name' => 'laravel/framework', 'version' => '11.x'], + ['name' => 'livewire/livewire', 'version' => '3.x'], + ] && $request->data()['token_limit'] === 10000; + }); }); test('it handles empty results', function () { @@ -148,27 +124,18 @@ $roster = Mockery::mock(Roster::class); $roster->shouldReceive('packages')->andReturn($packages); - $mockResponse = Mockery::mock(Response::class); - $mockResponse->shouldReceive('successful')->andReturn(true); - $mockResponse->shouldReceive('json')->andReturn(['results' => []]); - - $mockClient = Mockery::mock(PendingRequest::class); - $mockClient->shouldReceive('asJson')->andReturnSelf(); - $mockClient->shouldReceive('post')->andReturn($mockResponse); - - $tool = Mockery::mock(SearchDocs::class, [$roster])->makePartial(); - $tool->shouldReceive('client')->andReturn($mockClient); + Http::fake([ + 'https://boost.laravel.com/api/docs' => Http::response('Empty response', 200), + ]); + $tool = new SearchDocs($roster); $result = $tool->handle(['queries' => 'nonexistent']); expect($result)->toBeInstanceOf(ToolResult::class); $data = $result->toArray(); - expect($data['isError'])->toBeFalse(); - - $content = json_decode($data['content'][0]['text'], true); - expect($content['knowledge_count'])->toBe(0); - expect($content['knowledge'])->toBe(''); + expect($data['isError'])->toBeFalse() + ->and($data['content'][0]['text'])->toBe('Empty response'); }); test('it uses custom token_limit when provided', function () { @@ -177,25 +144,18 @@ $roster = Mockery::mock(Roster::class); $roster->shouldReceive('packages')->andReturn($packages); - $mockResponse = Mockery::mock(Response::class); - $mockResponse->shouldReceive('successful')->andReturn(true); - $mockResponse->shouldReceive('json')->andReturn(['results' => []]); - - $mockClient = Mockery::mock(PendingRequest::class); - $mockClient->shouldReceive('asJson')->andReturnSelf(); - $mockClient->shouldReceive('post')->with( - 'https://boost.laravel.com/api/docs', - Mockery::on(function ($payload) { - return $payload['token_limit'] === 5000; - }) - )->andReturn($mockResponse); - - $tool = Mockery::mock(SearchDocs::class, [$roster])->makePartial(); - $tool->shouldReceive('client')->andReturn($mockClient); + Http::fake([ + 'https://boost.laravel.com/api/docs' => Http::response('Custom token limit results', 200), + ]); + $tool = new SearchDocs($roster); $result = $tool->handle(['queries' => 'test', 'token_limit' => 5000]); expect($result)->toBeInstanceOf(ToolResult::class); + + Http::assertSent(function ($request) { + return $request->data()['token_limit'] === 5000; + }); }); test('it caps token_limit at maximum of 1000000', function () { @@ -204,23 +164,16 @@ $roster = Mockery::mock(Roster::class); $roster->shouldReceive('packages')->andReturn($packages); - $mockResponse = Mockery::mock(Response::class); - $mockResponse->shouldReceive('successful')->andReturn(true); - $mockResponse->shouldReceive('json')->andReturn(['results' => []]); - - $mockClient = Mockery::mock(PendingRequest::class); - $mockClient->shouldReceive('asJson')->andReturnSelf(); - $mockClient->shouldReceive('post')->with( - 'https://boost.laravel.com/api/docs', - Mockery::on(function ($payload) { - return $payload['token_limit'] === 1000000; // Should be capped at 1M - }) - )->andReturn($mockResponse); - - $tool = Mockery::mock(SearchDocs::class, [$roster])->makePartial(); - $tool->shouldReceive('client')->andReturn($mockClient); + Http::fake([ + 'https://boost.laravel.com/api/docs' => Http::response('Capped token limit results', 200), + ]); - $result = $tool->handle(['queries' => 'test', 'token_limit' => 2000000]); // Request 2M but get capped at 1M + $tool = new SearchDocs($roster); + $result = $tool->handle(['queries' => 'test', 'token_limit' => 2000000]); expect($result)->toBeInstanceOf(ToolResult::class); + + Http::assertSent(function ($request) { + return $request->data()['token_limit'] === 1000000; + }); });