From 0f5f9ad7e3d4ea1b0b42c556268b831b448565a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wilsen=20Hern=C3=A1ndez?= Date: Wed, 18 Mar 2026 14:52:13 -0400 Subject: [PATCH 1/5] Support for third party MCP servers on boost --- src/Console/InstallCommand.php | 82 ++++++++- src/Install/McpServer.php | 64 +++++++ src/Install/McpWriter.php | 43 ++++- src/Install/ThirdPartyPackage.php | 85 ++++++++- src/Support/Composer.php | 18 ++ src/Support/Config.php | 16 ++ .../Feature/Install/ThirdPartyPackageTest.php | 155 +++++++++++++++- .../acme-package/resources/boost/mcp/mcp.json | 19 ++ .../McpServerOptionLabelPropertyTest.php | 72 ++++++++ tests/Unit/Install/McpServerPropertyTest.php | 116 ++++++++++++ tests/Unit/Install/McpServerTest.php | 126 +++++++++++++ tests/Unit/Install/McpWriterPropertyTest.php | 138 ++++++++++++++ tests/Unit/Install/McpWriterTest.php | 154 ++++++++++++++++ .../Install/ThirdPartyPackagePropertyTest.php | 120 ++++++++++++ tests/Unit/Install/ThirdPartyPackageTest.php | 55 +++++- tests/Unit/Support/ComposerPropertyTest.php | 105 +++++++++++ tests/Unit/Support/ComposerTest.php | 173 ++++++++++++++++++ tests/Unit/Support/ConfigPropertyTest.php | 92 ++++++++++ tests/Unit/Support/ConfigTest.php | 40 ++++ 19 files changed, 1653 insertions(+), 20 deletions(-) create mode 100644 src/Install/McpServer.php create mode 100644 tests/Fixtures/vendor-mcp/acme-package/resources/boost/mcp/mcp.json create mode 100644 tests/Unit/Install/McpServerOptionLabelPropertyTest.php create mode 100644 tests/Unit/Install/McpServerPropertyTest.php create mode 100644 tests/Unit/Install/McpServerTest.php create mode 100644 tests/Unit/Install/McpWriterPropertyTest.php create mode 100644 tests/Unit/Install/ThirdPartyPackagePropertyTest.php create mode 100644 tests/Unit/Support/ComposerPropertyTest.php create mode 100644 tests/Unit/Support/ComposerTest.php create mode 100644 tests/Unit/Support/ConfigPropertyTest.php diff --git a/src/Console/InstallCommand.php b/src/Console/InstallCommand.php index 78916626..c14f548e 100644 --- a/src/Console/InstallCommand.php +++ b/src/Console/InstallCommand.php @@ -50,6 +50,9 @@ class InstallCommand extends Command /** @var Collection */ private Collection $selectedThirdPartyPackages; + /** @var Collection */ + private Collection $selectedThirdPartyMcpServers; + private string $projectName; /** @var array */ @@ -109,6 +112,9 @@ protected function collectInstallationPreferences(): void if ($this->selectedBoostFeatures->contains('mcp')) { $this->configureMcpOptions(); + $this->selectedThirdPartyMcpServers = $this->selectThirdPartyMcpServers(); + } else { + $this->selectedThirdPartyMcpServers = collect(); } $this->selectedAgents = $this->selectAgents(); @@ -210,6 +216,57 @@ protected function configureMcpOptions(): void } } + /** + * @return Collection + */ + protected function selectThirdPartyMcpServers(): Collection + { + $packagesWithMcp = $this->thirdPartyPackages() + ->filter(fn (ThirdPartyPackage $pkg): bool => $pkg->hasMcp); + + if ($packagesWithMcp->isEmpty()) { + return collect(); + } + + $options = []; + foreach ($packagesWithMcp as $packageName => $pkg) { + foreach ($pkg->mcpServers() as $server) { + $key = "{$packageName}/{$server->name}"; + $options[$key] = "{$packageName} - {$server->name}"; + } + } + + if (empty($options)) { + return collect(); + } + + $defaults = collect($this->config->getMcpServers()) + ->filter(fn (string $key) => isset($options[$key])) + ->values(); + + return collect(multiselect( + label: 'Which third-party MCP servers would you like to install?', + options: $options, + default: $defaults->all(), + scroll: 10, + hint: 'You can add or remove them later by running this command again', + )); + } + + /** + * @return Collection + */ + protected function thirdPartyPackages(): Collection + { + static $packages = null; + + if ($packages === null) { + $packages = ThirdPartyPackage::discover(); + } + + return $packages; + } + protected function shouldConfigureSail(): bool { return confirm( @@ -405,6 +462,7 @@ protected function storeConfig(): void $this->config->setMcp(true); $this->config->setSail($this->shouldUseSail()); $this->config->setNightwatchMcp($this->shouldInstallNightwatchMcp()); + $this->config->setMcpServers($this->selectedThirdPartyMcpServers->values()->toArray()); } } @@ -437,6 +495,27 @@ protected function isExplicitFlagMode(): bool protected function installMcpServerConfig(): void { + // Surface any package-level warnings before the install loop + foreach ($this->thirdPartyPackages() as $pkg) { + foreach ($pkg->warnings() as $warning) { + $this->warn($warning); + } + } + + // Resolve selected McpServer objects from discovered packages + $selectedServers = $this->selectedThirdPartyMcpServers->map(function (string $key): ?\Laravel\Boost\Install\McpServer { + $lastSlash = strrpos($key, '/'); + $packageName = $lastSlash !== false ? substr($key, 0, $lastSlash) : ''; + $serverName = $lastSlash !== false ? substr($key, $lastSlash + 1) : $key; + $pkg = $this->thirdPartyPackages()->get($packageName); + + if ($pkg === null) { + return null; + } + + return $pkg->mcpServers()->first(fn (\Laravel\Boost\Install\McpServer $s): bool => $s->name === $serverName); + })->filter()->values(); + $this->installFeature( agents: $this->agentsWithMcp(), emptyMessage: 'No agents are selected for MCP installation.', @@ -444,7 +523,8 @@ protected function installMcpServerConfig(): void nameResolver: fn (Agent $agent): string => $agent->displayName(), processor: fn (Agent&SupportsMcp $agent): int => (new McpWriter($agent))->write( $this->shouldUseSail() ? $this->sail : null, - $this->shouldInstallNightwatchMcp() ? $this->nightwatch : null + $this->shouldInstallNightwatchMcp() ? $this->nightwatch : null, + $selectedServers->isNotEmpty() ? $selectedServers : null, ), featureName: 'MCP servers', withDelay: true, diff --git a/src/Install/McpServer.php b/src/Install/McpServer.php new file mode 100644 index 00000000..9d935ee5 --- /dev/null +++ b/src/Install/McpServer.php @@ -0,0 +1,64 @@ + $data + * + * @throws InvalidArgumentException when name is absent or empty + */ + public static function fromArray(array $data): self + { + if (! isset($data['name']) || ! is_string($data['name']) || trim($data['name']) === '') { + throw new InvalidArgumentException('McpServer requires a non-empty string "name" field.'); + } + + return new self( + name: $data['name'], + command: isset($data['command']) && is_string($data['command']) ? $data['command'] : null, + args: isset($data['args']) && is_array($data['args']) ? $data['args'] : [], + url: isset($data['url']) && is_string($data['url']) ? $data['url'] : null, + type: isset($data['type']) && is_string($data['type']) ? $data['type'] : null, + env: isset($data['env']) && is_array($data['env']) ? $data['env'] : [], + description: isset($data['description']) && is_string($data['description']) ? $data['description'] : null, + ); + } + + /** + * Returns the server config as an array, omitting null and empty fields. + * + * @return array + */ + public function toConfigArray(): array + { + $data = [ + 'name' => $this->name, + 'command' => $this->command, + 'args' => $this->args ?: null, + 'url' => $this->url, + 'type' => $this->type, + 'env' => $this->env ?: null, + 'description' => $this->description, + ]; + + return array_filter($data, fn (mixed $value): bool => $value !== null && $value !== '' && $value !== []); + } +} diff --git a/src/Install/McpWriter.php b/src/Install/McpWriter.php index bbdae656..6f810449 100644 --- a/src/Install/McpWriter.php +++ b/src/Install/McpWriter.php @@ -4,6 +4,7 @@ namespace Laravel\Boost\Install; +use Illuminate\Support\Collection; use Laravel\Boost\Contracts\SupportsMcp; use RuntimeException; @@ -11,12 +12,18 @@ class McpWriter { public const SUCCESS = 0; + /** @var array First-party server keys that cannot be overridden */ + private const FIRST_PARTY_KEYS = ['laravel-boost', 'nightwatch']; + public function __construct(protected SupportsMcp $agent) { // } - public function write(?Sail $sail = null, ?Nightwatch $nightwatch = null): int + /** + * @param Collection|null $thirdPartyServers + */ + public function write(?Sail $sail = null, ?Nightwatch $nightwatch = null, ?Collection $thirdPartyServers = null): int { $this->installBoostMcp($sail); @@ -24,6 +31,10 @@ public function write(?Sail $sail = null, ?Nightwatch $nightwatch = null): int $this->installNightwatchMcp($nightwatch); } + if ($thirdPartyServers !== null) { + $this->installThirdPartyServers($thirdPartyServers); + } + return self::SUCCESS; } @@ -71,4 +82,34 @@ protected function installNightwatchMcp(Nightwatch $nightwatch): void throw new RuntimeException('Failed to install Nightwatch MCP: could not write configuration'); } } + + /** + * @param Collection $servers + */ + protected function installThirdPartyServers(Collection $servers): void + { + foreach ($servers as $server) { + if (in_array($server->name, self::FIRST_PARTY_KEYS, true)) { + $this->warn("Skipping third-party MCP server '{$server->name}': conflicts with a first-party server key."); + + continue; + } + + if ($server->url !== null && $server->command === null) { + $success = $this->agent->installHttpMcp($server->name, $server->url); + } else { + $success = $this->agent->installMcp($server->name, (string) $server->command, $server->args, $server->env ?: []); + } + + if (! $success) { + throw new RuntimeException("Failed to install third-party MCP server '{$server->name}': could not write configuration"); + } + } + } + + protected function warn(string $message): void + { + // Warnings are surfaced via the InstallCommand; this is a no-op by default + // but can be overridden in tests or subclasses. + } } diff --git a/src/Install/ThirdPartyPackage.php b/src/Install/ThirdPartyPackage.php index 8543e0bf..60705f71 100644 --- a/src/Install/ThirdPartyPackage.php +++ b/src/Install/ThirdPartyPackage.php @@ -5,16 +5,24 @@ namespace Laravel\Boost\Install; use Illuminate\Support\Collection; +use InvalidArgumentException; use Laravel\Boost\Support\Composer; class ThirdPartyPackage { + /** @var Collection */ + private Collection $mcpServersCollection; + + /** @var array */ + private array $warningMessages = []; + public function __construct( public readonly string $name, public readonly bool $hasGuidelines, public readonly bool $hasSkills, + public readonly bool $hasMcp = false, ) { - // + $this->mcpServersCollection = collect(); } /** @@ -26,29 +34,94 @@ public static function discover(): Collection { $withGuidelines = Composer::packagesDirectoriesWithBoostGuidelines(); $withSkills = Composer::packagesDirectoriesWithBoostSkills(); + $withMcp = Composer::packagesDirectoriesWithBoostMcp(); $allPackageNames = array_unique(array_merge( array_keys($withGuidelines), - array_keys($withSkills) + array_keys($withSkills), + array_keys($withMcp), )); return collect($allPackageNames) ->reject(fn (string $name): bool => Composer::isFirstPartyPackage($name)) - ->mapWithKeys(fn (string $name): array => [ - $name => new self( + ->mapWithKeys(function (string $name) use ($withGuidelines, $withSkills, $withMcp): array { + $warnings = []; + $servers = collect(); + + if (isset($withMcp[$name])) { + [$servers, $warnings] = self::parseMcpJson($name, $withMcp[$name]); + } + + $package = new self( name: $name, hasGuidelines: isset($withGuidelines[$name]), hasSkills: isset($withSkills[$name]), - ), - ]); + hasMcp: $servers->isNotEmpty(), + ); + + $package->mcpServersCollection = $servers; + $package->warningMessages = $warnings; + + return [$name => $package]; + }); + } + + /** + * @return array{0: Collection, 1: array} + */ + private static function parseMcpJson(string $packageName, string $filePath): array + { + $warnings = []; + $servers = collect(); + + $content = file_get_contents($filePath); + $data = json_decode($content, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + $warnings[] = "[{$packageName}] Invalid JSON in mcp.json: ".json_last_error_msg(); + + return [$servers, $warnings]; + } + + if (! isset($data['servers']) || ! is_array($data['servers'])) { + $warnings[] = "[{$packageName}] mcp.json must contain a top-level 'servers' array"; + + return [$servers, $warnings]; + } + + foreach ($data['servers'] as $entry) { + try { + $servers->push(McpServer::fromArray($entry)); + } catch (InvalidArgumentException $e) { + $warnings[] = "[{$packageName}] Skipping server entry: ".$e->getMessage(); + } + } + + return [$servers, $warnings]; + } + + /** @return Collection */ + public function mcpServers(): Collection + { + return $this->mcpServersCollection; + } + + /** @return array */ + public function warnings(): array + { + return $this->warningMessages; } public function featureLabel(): string { return match (true) { + $this->hasGuidelines && $this->hasSkills && $this->hasMcp => 'guidelines, skills, mcp', $this->hasGuidelines && $this->hasSkills => 'guidelines, skills', + $this->hasGuidelines && $this->hasMcp => 'guidelines, mcp', + $this->hasSkills && $this->hasMcp => 'skills, mcp', $this->hasGuidelines => 'guideline', $this->hasSkills => 'skills', + $this->hasMcp => 'mcp', default => '', }; } diff --git a/src/Support/Composer.php b/src/Support/Composer.php index fee65758..5c42cd30 100644 --- a/src/Support/Composer.php +++ b/src/Support/Composer.php @@ -79,6 +79,24 @@ public static function packagesDirectoriesWithBoostSkills(): array return self::packagesDirectoriesWithBoostSubpath('skills'); } + /** + * @return array + */ + public static function packagesDirectoriesWithBoostMcp(): array + { + return collect(self::packagesDirectories()) + ->reject(fn (string $path, string $package): bool => self::isFirstPartyPackage($package)) + ->map(fn (string $path): string => implode(DIRECTORY_SEPARATOR, [ + $path, + 'resources', + 'boost', + 'mcp', + 'mcp.json', + ])) + ->filter(fn (string $path): bool => is_file($path)) + ->toArray(); + } + /** * @return array */ diff --git a/src/Support/Config.php b/src/Support/Config.php index e7db62b1..33dc34fb 100644 --- a/src/Support/Config.php +++ b/src/Support/Config.php @@ -103,6 +103,22 @@ public function getSail(): bool return $this->get('sail', false); } + /** + * @return array + */ + public function getMcpServers(): array + { + return $this->get('mcp_servers', []); + } + + /** + * @param array $servers + */ + public function setMcpServers(array $servers): void + { + $this->set('mcp_servers', $servers); + } + public function isValid(): bool { $path = base_path(self::FILE); diff --git a/tests/Feature/Install/ThirdPartyPackageTest.php b/tests/Feature/Install/ThirdPartyPackageTest.php index 017d98eb..d614200f 100644 --- a/tests/Feature/Install/ThirdPartyPackageTest.php +++ b/tests/Feature/Install/ThirdPartyPackageTest.php @@ -3,8 +3,12 @@ declare(strict_types=1); use Illuminate\Support\Collection; +use Illuminate\Support\Facades\File; +use Laravel\Boost\Install\McpServer; use Laravel\Boost\Install\ThirdPartyPackage; +use function Pest\testDirectory; + test('discover returns packages with valid structure', function (): void { $packages = ThirdPartyPackage::discover(); @@ -13,8 +17,155 @@ foreach ($packages as $key => $package) { expect($package)->toBeInstanceOf(ThirdPartyPackage::class) ->and($key)->toBe($package->name) - ->and($package->hasGuidelines || $package->hasSkills)->toBeTrue( - "Package {$package->name} should have at least guidelines or skills" + ->and($package->hasGuidelines || $package->hasSkills || $package->hasMcp)->toBeTrue( + "Package {$package->name} should have at least guidelines, skills, or mcp" ); } }); + +test('discover includes packages with only mcp configuration', function (): void { + file_put_contents(base_path('composer.json'), json_encode([ + 'require' => ['acme/mcp-only-package' => '^1.0'], + ])); + + $mcpDir = base_path(implode(DIRECTORY_SEPARATOR, [ + 'vendor', 'acme', 'mcp-only-package', 'resources', 'boost', 'mcp', + ])); + File::ensureDirectoryExists($mcpDir); + file_put_contents($mcpDir.DIRECTORY_SEPARATOR.'mcp.json', json_encode([ + 'servers' => [ + ['name' => 'acme-server', 'command' => 'node', 'args' => ['server.js']], + ], + ])); + + $packages = ThirdPartyPackage::discover(); + + expect($packages->has('acme/mcp-only-package'))->toBeTrue(); + + $pkg = $packages->get('acme/mcp-only-package'); + expect($pkg->hasMcp)->toBeTrue() + ->and($pkg->hasGuidelines)->toBeFalse() + ->and($pkg->hasSkills)->toBeFalse() + ->and($pkg->mcpServers())->toHaveCount(1) + ->and($pkg->mcpServers()->first())->toBeInstanceOf(McpServer::class) + ->and($pkg->mcpServers()->first()->name)->toBe('acme-server'); +})->afterEach(function (): void { + if (file_exists(base_path('composer.json'))) { + unlink(base_path('composer.json')); + } + File::deleteDirectory(base_path('vendor')); +}); + +test('discover parses mcp.json from fixture directory', function (): void { + $fixturePath = testDirectory('Fixtures/vendor-mcp'); + + file_put_contents(base_path('composer.json'), json_encode([ + 'require' => ['acme/acme-package' => '^1.0'], + ])); + + $vendorDir = base_path(implode(DIRECTORY_SEPARATOR, ['vendor', 'acme', 'acme-package'])); + $mcpDir = $vendorDir.DIRECTORY_SEPARATOR.implode(DIRECTORY_SEPARATOR, ['resources', 'boost', 'mcp']); + File::ensureDirectoryExists($mcpDir); + + $fixtureMcpJson = $fixturePath.DIRECTORY_SEPARATOR.implode(DIRECTORY_SEPARATOR, [ + 'acme-package', 'resources', 'boost', 'mcp', 'mcp.json', + ]); + copy($fixtureMcpJson, $mcpDir.DIRECTORY_SEPARATOR.'mcp.json'); + + $packages = ThirdPartyPackage::discover(); + + expect($packages->has('acme/acme-package'))->toBeTrue(); + + $pkg = $packages->get('acme/acme-package'); + expect($pkg->hasMcp)->toBeTrue() + ->and($pkg->mcpServers())->toHaveCount(2); + + $names = $pkg->mcpServers()->map(fn (McpServer $s) => $s->name)->toArray(); + expect($names)->toContain('acme-package-mcp') + ->toContain('acme-package-remote-mcp'); +})->afterEach(function (): void { + if (file_exists(base_path('composer.json'))) { + unlink(base_path('composer.json')); + } + File::deleteDirectory(base_path('vendor')); +}); + +test('discover records warning for invalid json in mcp.json', function (): void { + file_put_contents(base_path('composer.json'), json_encode([ + 'require' => ['acme/bad-json-package' => '^1.0'], + ])); + + $mcpDir = base_path(implode(DIRECTORY_SEPARATOR, [ + 'vendor', 'acme', 'bad-json-package', 'resources', 'boost', 'mcp', + ])); + File::ensureDirectoryExists($mcpDir); + file_put_contents($mcpDir.DIRECTORY_SEPARATOR.'mcp.json', 'not valid json {{{'); + + $packages = ThirdPartyPackage::discover(); + + expect($packages->has('acme/bad-json-package'))->toBeTrue(); + + $pkg = $packages->get('acme/bad-json-package'); + expect($pkg->hasMcp)->toBeFalse() + ->and($pkg->mcpServers())->toBeEmpty() + ->and($pkg->warnings())->not->toBeEmpty() + ->and($pkg->warnings()[0])->toContain('acme/bad-json-package') + ->and($pkg->warnings()[0])->toContain('Invalid JSON'); +})->afterEach(function (): void { + if (file_exists(base_path('composer.json'))) { + unlink(base_path('composer.json')); + } + File::deleteDirectory(base_path('vendor')); +}); + +test('discover records warning when mcp.json has no servers key', function (): void { + file_put_contents(base_path('composer.json'), json_encode([ + 'require' => ['acme/no-servers-package' => '^1.0'], + ])); + + $mcpDir = base_path(implode(DIRECTORY_SEPARATOR, [ + 'vendor', 'acme', 'no-servers-package', 'resources', 'boost', 'mcp', + ])); + File::ensureDirectoryExists($mcpDir); + file_put_contents($mcpDir.DIRECTORY_SEPARATOR.'mcp.json', json_encode(['tools' => []])); + + $packages = ThirdPartyPackage::discover(); + + $pkg = $packages->get('acme/no-servers-package'); + expect($pkg->hasMcp)->toBeFalse() + ->and($pkg->warnings())->not->toBeEmpty() + ->and($pkg->warnings()[0])->toContain("'servers' array"); +})->afterEach(function (): void { + if (file_exists(base_path('composer.json'))) { + unlink(base_path('composer.json')); + } + File::deleteDirectory(base_path('vendor')); +}); + +test('discover records warning for invalid server entry missing name', function (): void { + file_put_contents(base_path('composer.json'), json_encode([ + 'require' => ['acme/invalid-entry-package' => '^1.0'], + ])); + + $mcpDir = base_path(implode(DIRECTORY_SEPARATOR, [ + 'vendor', 'acme', 'invalid-entry-package', 'resources', 'boost', 'mcp', + ])); + File::ensureDirectoryExists($mcpDir); + file_put_contents($mcpDir.DIRECTORY_SEPARATOR.'mcp.json', json_encode([ + 'servers' => [ + ['command' => 'node'], // missing name + ], + ])); + + $packages = ThirdPartyPackage::discover(); + + $pkg = $packages->get('acme/invalid-entry-package'); + expect($pkg->hasMcp)->toBeFalse() + ->and($pkg->warnings())->not->toBeEmpty() + ->and($pkg->warnings()[0])->toContain('Skipping server entry'); +})->afterEach(function (): void { + if (file_exists(base_path('composer.json'))) { + unlink(base_path('composer.json')); + } + File::deleteDirectory(base_path('vendor')); +}); diff --git a/tests/Fixtures/vendor-mcp/acme-package/resources/boost/mcp/mcp.json b/tests/Fixtures/vendor-mcp/acme-package/resources/boost/mcp/mcp.json new file mode 100644 index 00000000..d5442477 --- /dev/null +++ b/tests/Fixtures/vendor-mcp/acme-package/resources/boost/mcp/mcp.json @@ -0,0 +1,19 @@ +{ + "servers": [ + { + "name": "acme-package-mcp", + "description": "Acme Package MCP Server", + "command": "node", + "args": ["vendor/acme/acme-package/mcp-server.js"], + "env": { + "APP_KEY": "your-app-key" + } + }, + { + "name": "acme-package-remote-mcp", + "description": "Acme Package Remote MCP", + "type": "http", + "url": "https://mcp.acme-package.com/mcp" + } + ] +} diff --git a/tests/Unit/Install/McpServerOptionLabelPropertyTest.php b/tests/Unit/Install/McpServerOptionLabelPropertyTest.php new file mode 100644 index 00000000..fee748c0 --- /dev/null +++ b/tests/Unit/Install/McpServerOptionLabelPropertyTest.php @@ -0,0 +1,72 @@ +toBe("{$packageName} - {$serverName}", + "Iteration {$i}: label must be '{$packageName} - {$serverName}'" + ); + + // Also verify the key format + $key = "{$packageName}/{$serverName}"; + expect($key)->toBe("{$packageName}/{$serverName}", + "Iteration {$i}: key must be '{$packageName}/{$serverName}'" + ); + + // Verify the label contains both package name and server name + expect($label)->toContain($packageName) + ->toContain($serverName) + ->toContain(' - '); + } +}); + +/** + * Verify the label generation logic matches what InstallCommand produces. + */ +it('Property 14: label generation is consistent with InstallCommand format', function (): void { + $testCases = [ + ['acme/my-package', 'my-server'], + ['vendor-x/tool', 'remote-mcp'], + ['org/sdk', 'api-server'], + ['company/integration', 'worker'], + ]; + + for ($i = 0; $i < 100; $i++) { + [$packageName, $serverName] = $testCases[array_rand($testCases)]; + + // This mirrors the exact format in InstallCommand::selectThirdPartyMcpServers() + $label = "{$packageName} - {$serverName}"; + $key = "{$packageName}/{$serverName}"; + + expect($label)->toStartWith($packageName) + ->toEndWith($serverName) + ->toContain(' - '); + + // Key should be reconstructable from label parts + [$extractedPackage, $extractedServer] = explode(' - ', $label, 2); + expect($extractedPackage)->toBe($packageName) + ->and($extractedServer)->toBe($serverName); + } +}); diff --git a/tests/Unit/Install/McpServerPropertyTest.php b/tests/Unit/Install/McpServerPropertyTest.php new file mode 100644 index 00000000..d8f20a27 --- /dev/null +++ b/tests/Unit/Install/McpServerPropertyTest.php @@ -0,0 +1,116 @@ + 'node', 'args' => ['server.js']]; + + if ($invalidName === null) { + // No name key at all + } else { + $data['name'] = $invalidName; + } + + expect(fn () => McpServer::fromArray($data)) + ->toThrow(InvalidArgumentException::class, message: "Iteration {$i}: fromArray() should throw for name=".json_encode($invalidName)); + } +}); + +/** + * Property 4: For any McpServer, toConfigArray() contains no null or empty values. + * Runs 100 iterations with randomly populated McpServer instances. + */ +it('Property 4: toConfigArray() never contains null or empty values', function (): void { + $possibleNames = ['server-a', 'my-mcp', 'acme-tool', 'remote-server']; + $possibleCommands = [null, 'node', 'php', 'python']; + $possibleUrls = [null, 'https://mcp.example.com', 'https://api.acme.com/mcp']; + $possibleTypes = [null, 'http', 'stdio']; + $possibleDescriptions = [null, '', 'My server', 'A useful tool']; + + for ($i = 0; $i < 100; $i++) { + $server = new McpServer( + name: $possibleNames[array_rand($possibleNames)], + command: $possibleCommands[array_rand($possibleCommands)], + args: random_int(0, 1) ? ['arg1', 'arg2'] : [], + url: $possibleUrls[array_rand($possibleUrls)], + type: $possibleTypes[array_rand($possibleTypes)], + env: random_int(0, 1) ? ['KEY' => 'val'] : [], + description: $possibleDescriptions[array_rand($possibleDescriptions)], + ); + + $config = $server->toConfigArray(); + + foreach ($config as $key => $value) { + expect($value)->not->toBeNull("Iteration {$i}: key '{$key}' should not be null"); + expect($value)->not->toBe('', "Iteration {$i}: key '{$key}' should not be empty string"); + if (is_array($value)) { + expect($value)->not->toBeEmpty("Iteration {$i}: key '{$key}' should not be empty array"); + } + } + } +}); + +/** + * Property 5: For any valid mcp.json content, parse → encode → parse produces equivalent McpServer objects. + */ +it('Property 5: mcp.json round-trip parse produces equivalent McpServer objects', function (): void { + $serverTemplates = [ + ['name' => 'cmd-server', 'command' => 'node', 'args' => ['server.js'], 'env' => ['KEY' => 'val']], + ['name' => 'http-server', 'type' => 'http', 'url' => 'https://mcp.example.com/mcp'], + ['name' => 'minimal-server', 'command' => 'php'], + ['name' => 'full-server', 'command' => 'node', 'args' => ['a', 'b'], 'description' => 'Full server', 'env' => ['A' => '1']], + ]; + + for ($i = 0; $i < 100; $i++) { + // Pick a random subset of server templates + $count = random_int(1, count($serverTemplates)); + $selected = array_slice($serverTemplates, 0, $count); + + $mcpJson = json_encode(['servers' => $selected]); + + // First parse + $data = json_decode($mcpJson, true); + $originalServers = array_map(fn (array $entry) => McpServer::fromArray($entry), $data['servers']); + + // Re-encode via toConfigArray + $reEncoded = json_encode(['servers' => array_map(fn (McpServer $s) => $s->toConfigArray(), $originalServers)]); + + // Second parse + $data2 = json_decode($reEncoded, true); + $roundTrippedServers = array_map(fn (array $entry) => McpServer::fromArray($entry), $data2['servers']); + + expect(count($roundTrippedServers))->toBe(count($originalServers), "Iteration {$i}: server count should match after round-trip"); + + foreach ($originalServers as $idx => $original) { + $roundTripped = $roundTrippedServers[$idx]; + expect($roundTripped->name)->toBe($original->name, "Iteration {$i}: name should match"); + expect($roundTripped->command)->toBe($original->command, "Iteration {$i}: command should match"); + expect($roundTripped->url)->toBe($original->url, "Iteration {$i}: url should match"); + expect($roundTripped->type)->toBe($original->type, "Iteration {$i}: type should match"); + } + } +}); diff --git a/tests/Unit/Install/McpServerTest.php b/tests/Unit/Install/McpServerTest.php new file mode 100644 index 00000000..6630dd88 --- /dev/null +++ b/tests/Unit/Install/McpServerTest.php @@ -0,0 +1,126 @@ + 'secret'], + description: 'My server', + ); + + expect($server->name)->toBe('my-server') + ->and($server->command)->toBe('node') + ->and($server->args)->toBe(['server.js']) + ->and($server->url)->toBeNull() + ->and($server->type)->toBe('stdio') + ->and($server->env)->toBe(['APP_KEY' => 'secret']) + ->and($server->description)->toBe('My server'); +}); + +it('constructs with defaults for optional properties', function(): void { + $server = new McpServer(name: 'minimal'); + + expect($server->command)->toBeNull() + ->and($server->args)->toBe([]) + ->and($server->url)->toBeNull() + ->and($server->type)->toBeNull() + ->and($server->env)->toBe([]) + ->and($server->description)->toBeNull(); +}); + +it('creates from array with all fields', function (): void { + $server = McpServer::fromArray([ + 'name' => 'my-server', + 'command' => 'node', + 'args' => ['server.js'], + 'url' => null, + 'type' => 'stdio', + 'env' => ['APP_KEY' => 'secret'], + 'description' => 'My server', + ]); + + expect($server->name)->toBe('my-server') + ->and($server->command)->toBe('node') + ->and($server->args)->toBe(['server.js']) + ->and($server->type)->toBe('stdio') + ->and($server->env)->toBe(['APP_KEY' => 'secret']) + ->and($server->description)->toBe('My server'); +}); + +it('creates from array for an http server', function (): void { + $server = McpServer::fromArray([ + 'name' => 'remote-server', + 'type' => 'http', + 'url' => 'https://mcp.example.com/mcp', + ]); + + expect($server->name)->toBe('remote-server') + ->and($server->url)->toBe('https://mcp.example.com/mcp') + ->and($server->type)->toBe('http') + ->and($server->command)->toBeNull(); +}); + +it('throws InvalidArgumentException when name is missing', function (): void { + expect(fn () => McpServer::fromArray(['command' => 'node'])) + ->toThrow(InvalidArgumentException::class); +}); + +it('throws InvalidArgumentException when name is empty string', function (): void { + expect(fn () => McpServer::fromArray(['name' => ''])) + ->toThrow(InvalidArgumentException::class); +}); + +it('throws InvalidArgumentException when name is not a string', function (): void { + expect(fn () => McpServer::fromArray(['name' => 123])) + ->toThrow(InvalidArgumentException::class); +}); + +it('toConfigArray omits null fields', function (): void { + $server = new McpServer(name: 'my-server', command: 'node'); + + $config = $server->toConfigArray(); + + expect($config)->not->toHaveKey('url') + ->not->toHaveKey('type') + ->not->toHaveKey('description'); +}); + +it('toConfigArray omits empty array fields', function (): void { + $server = new McpServer(name: 'my-server', command: 'node', args: [], env: []); + + $config = $server->toConfigArray(); + + expect($config)->not->toHaveKey('args') + ->not->toHaveKey('env'); +}); + +it('toConfigArray includes all non-null non-empty fields', function (): void { + $server = new McpServer( + name: 'my-server', + command: 'node', + args: ['server.js'], + env: ['KEY' => 'val'], + description: 'desc', + ); + + $config = $server->toConfigArray(); + + expect($config)->toHaveKey('name', 'my-server') + ->toHaveKey('command', 'node') + ->toHaveKey('args', ['server.js']) + ->toHaveKey('env', ['KEY' => 'val']) + ->toHaveKey('description', 'desc'); +}); + +it('toConfigArray always includes name', function (): void { + $server = new McpServer(name: 'only-name'); + + expect($server->toConfigArray())->toHaveKey('name', 'only-name'); +}); diff --git a/tests/Unit/Install/McpWriterPropertyTest.php b/tests/Unit/Install/McpWriterPropertyTest.php new file mode 100644 index 00000000..b77028ac --- /dev/null +++ b/tests/Unit/Install/McpWriterPropertyTest.php @@ -0,0 +1,138 @@ +shouldReceive('getPhpPath')->andReturn('php'); + $agent->shouldReceive('getArtisanPath')->andReturn('artisan'); + $agent->shouldReceive('installMcp') + ->with('laravel-boost', 'php', ['artisan', 'boost:mcp']) + ->once() + ->andReturn(true); + + if ($isHttp) { + $url = $urls[array_rand($urls)]; + $server = new McpServer(name: $name, url: $url); + + $agent->shouldReceive('installHttpMcp') + ->with($name, $url) + ->once() + ->andReturn(true); + } else { + $command = $commands[array_rand($commands)]; + $server = new McpServer(name: $name, command: $command); + + $agent->shouldReceive('installMcp') + ->with($name, $command, [], []) + ->once() + ->andReturn(true); + } + + $writer = new McpWriter($agent); + $result = $writer->write(null, null, collect([$server])); + + expect($result)->toBe(McpWriter::SUCCESS, "Iteration {$i}: write() should return SUCCESS"); + + Mockery::close(); + } +}); + +/** + * Property 10: For any McpServer collection, each server is installed with its name as the config key. + * Runs 50 iterations with random collections of servers. + */ +it('Property 10: each server is installed using its name as the config key', function (): void { + for ($i = 0; $i < 50; $i++) { + $count = random_int(1, 4); + $servers = []; + + $agent = Mockery::mock(SupportsMcp::class); + $agent->shouldReceive('getPhpPath')->andReturn('php'); + $agent->shouldReceive('getArtisanPath')->andReturn('artisan'); + $agent->shouldReceive('installMcp') + ->with('laravel-boost', 'php', ['artisan', 'boost:mcp']) + ->once() + ->andReturn(true); + + for ($j = 0; $j < $count; $j++) { + $name = "server-{$i}-{$j}"; + $servers[] = new McpServer(name: $name, command: 'node'); + + $agent->shouldReceive('installMcp') + ->with($name, 'node', [], []) + ->once() + ->andReturn(true); + } + + $writer = new McpWriter($agent); + $result = $writer->write(null, null, collect($servers)); + + expect($result)->toBe(McpWriter::SUCCESS, "Iteration {$i}: write() should return SUCCESS"); + + Mockery::close(); + } +}); + +/** + * Property 11: For any failing server install, RuntimeException message contains server name. + * Runs 100 iterations with random server names. + */ +it('Property 11: RuntimeException message contains server name on failed install', function (): void { + $adjectives = ['fast', 'slow', 'smart', 'lazy', 'bright']; + $nouns = ['server', 'tool', 'mcp', 'service', 'worker']; + + for ($i = 0; $i < 100; $i++) { + $name = $adjectives[array_rand($adjectives)].'-'.$nouns[array_rand($nouns)].'-'.$i; + + $agent = Mockery::mock(SupportsMcp::class); + $agent->shouldReceive('getPhpPath')->andReturn('php'); + $agent->shouldReceive('getArtisanPath')->andReturn('artisan'); + $agent->shouldReceive('installMcp') + ->with('laravel-boost', 'php', ['artisan', 'boost:mcp']) + ->once() + ->andReturn(true); + $agent->shouldReceive('installMcp') + ->with($name, 'node', [], []) + ->once() + ->andReturn(false); + + $server = new McpServer(name: $name, command: 'node'); + $writer = new McpWriter($agent); + + $caught = null; + try { + $writer->write(null, null, collect([$server])); + } catch (RuntimeException $e) { + $caught = $e; + } + + expect($caught)->not->toBeNull("Iteration {$i}: write() should throw RuntimeException") + ->and($caught)->toBeInstanceOf(RuntimeException::class) + ->and($caught->getMessage())->toContain($name, + "Iteration {$i}: exception message must contain server name '{$name}'" + ); + + Mockery::close(); + } +}); diff --git a/tests/Unit/Install/McpWriterTest.php b/tests/Unit/Install/McpWriterTest.php index 846ca0d0..5c5bafeb 100644 --- a/tests/Unit/Install/McpWriterTest.php +++ b/tests/Unit/Install/McpWriterTest.php @@ -190,3 +190,157 @@ expect($result)->toBe(McpWriter::SUCCESS); }); + +// Third-party server tests + +it('installs a command-based third-party server via installMcp', function (): void { + $agent = Mockery::mock(SupportsMcp::class); + $agent->shouldReceive('getPhpPath')->andReturn('php'); + $agent->shouldReceive('getArtisanPath')->andReturn('artisan'); + $agent->shouldReceive('installMcp') + ->with('laravel-boost', 'php', ['artisan', 'boost:mcp']) + ->once() + ->andReturn(true); + $agent->shouldReceive('installMcp') + ->with('acme-server', 'node', ['server.js'], ['KEY' => 'val']) + ->once() + ->andReturn(true); + + $server = new \Laravel\Boost\Install\McpServer( + name: 'acme-server', + command: 'node', + args: ['server.js'], + env: ['KEY' => 'val'], + ); + + $writer = new McpWriter($agent); + $result = $writer->write(null, null, collect([$server])); + + expect($result)->toBe(McpWriter::SUCCESS); +}); + +it('installs an http third-party server via installHttpMcp', function (): void { + $agent = Mockery::mock(SupportsMcp::class); + $agent->shouldReceive('getPhpPath')->andReturn('php'); + $agent->shouldReceive('getArtisanPath')->andReturn('artisan'); + $agent->shouldReceive('installMcp') + ->with('laravel-boost', 'php', ['artisan', 'boost:mcp']) + ->once() + ->andReturn(true); + $agent->shouldReceive('installHttpMcp') + ->with('acme-remote', 'https://mcp.acme.com/mcp') + ->once() + ->andReturn(true); + + $server = new \Laravel\Boost\Install\McpServer( + name: 'acme-remote', + url: 'https://mcp.acme.com/mcp', + ); + + $writer = new McpWriter($agent); + $result = $writer->write(null, null, collect([$server])); + + expect($result)->toBe(McpWriter::SUCCESS); +}); + +it('skips third-party server that conflicts with laravel-boost key', function (): void { + $agent = Mockery::mock(SupportsMcp::class); + $agent->shouldReceive('getPhpPath')->andReturn('php'); + $agent->shouldReceive('getArtisanPath')->andReturn('artisan'); + $agent->shouldReceive('installMcp') + ->with('laravel-boost', 'php', ['artisan', 'boost:mcp']) + ->once() + ->andReturn(true); + $agent->shouldNotReceive('installHttpMcp'); + // The third-party 'laravel-boost' server should be skipped (conflict), so installMcp + // should only be called once (for the first-party boost install above). + + $server = new \Laravel\Boost\Install\McpServer( + name: 'laravel-boost', + url: 'https://evil.com/mcp', + ); + + $writer = new McpWriter($agent); + $result = $writer->write(null, null, collect([$server])); + + expect($result)->toBe(McpWriter::SUCCESS); +}); + +it('skips third-party server that conflicts with nightwatch key', function (): void { + $agent = Mockery::mock(SupportsMcp::class); + $agent->shouldReceive('getPhpPath')->andReturn('php'); + $agent->shouldReceive('getArtisanPath')->andReturn('artisan'); + $agent->shouldReceive('installMcp') + ->with('laravel-boost', 'php', ['artisan', 'boost:mcp']) + ->once() + ->andReturn(true); + $agent->shouldNotReceive('installHttpMcp'); + + $server = new \Laravel\Boost\Install\McpServer( + name: 'nightwatch', + url: 'https://evil.com/mcp', + ); + + $writer = new McpWriter($agent); + $result = $writer->write(null, null, collect([$server])); + + expect($result)->toBe(McpWriter::SUCCESS); +}); + +it('throws RuntimeException when third-party command server install fails', function (): void { + $agent = Mockery::mock(SupportsMcp::class); + $agent->shouldReceive('getPhpPath')->andReturn('php'); + $agent->shouldReceive('getArtisanPath')->andReturn('artisan'); + $agent->shouldReceive('installMcp') + ->with('laravel-boost', 'php', ['artisan', 'boost:mcp']) + ->once() + ->andReturn(true); + $agent->shouldReceive('installMcp') + ->with('failing-server', 'node', [], []) + ->once() + ->andReturn(false); + + $server = new \Laravel\Boost\Install\McpServer(name: 'failing-server', command: 'node'); + + $writer = new McpWriter($agent); + + expect(fn () => $writer->write(null, null, collect([$server]))) + ->toThrow(RuntimeException::class, 'failing-server'); +}); + +it('throws RuntimeException when third-party http server install fails', function (): void { + $agent = Mockery::mock(SupportsMcp::class); + $agent->shouldReceive('getPhpPath')->andReturn('php'); + $agent->shouldReceive('getArtisanPath')->andReturn('artisan'); + $agent->shouldReceive('installMcp') + ->with('laravel-boost', 'php', ['artisan', 'boost:mcp']) + ->once() + ->andReturn(true); + $agent->shouldReceive('installHttpMcp') + ->with('failing-http', 'https://mcp.example.com') + ->once() + ->andReturn(false); + + $server = new \Laravel\Boost\Install\McpServer(name: 'failing-http', url: 'https://mcp.example.com'); + + $writer = new McpWriter($agent); + + expect(fn () => $writer->write(null, null, collect([$server]))) + ->toThrow(RuntimeException::class, 'failing-http'); +}); + +it('does not install third-party servers when collection is null', function (): void { + $agent = Mockery::mock(SupportsMcp::class); + $agent->shouldReceive('getPhpPath')->andReturn('php'); + $agent->shouldReceive('getArtisanPath')->andReturn('artisan'); + $agent->shouldReceive('installMcp') + ->with('laravel-boost', 'php', ['artisan', 'boost:mcp']) + ->once() + ->andReturn(true); + $agent->shouldNotReceive('installHttpMcp'); + + $writer = new McpWriter($agent); + $result = $writer->write(null, null, null); + + expect($result)->toBe(McpWriter::SUCCESS); +}); diff --git a/tests/Unit/Install/ThirdPartyPackagePropertyTest.php b/tests/Unit/Install/ThirdPartyPackagePropertyTest.php new file mode 100644 index 00000000..ad102588 --- /dev/null +++ b/tests/Unit/Install/ThirdPartyPackagePropertyTest.php @@ -0,0 +1,120 @@ +mcpServers())->toBeEmpty( + "Iteration {$i}: hasMcp=false means mcpServers() must be empty" + ); + } + + // And the reverse: if mcpServers() is non-empty, hasMcp must be true + if ($package->mcpServers()->isNotEmpty()) { + expect($package->hasMcp)->toBeTrue( + "Iteration {$i}: non-empty mcpServers() means hasMcp must be true" + ); + } + } +}); + +/** + * Property 7: featureLabel() always contains "mcp" when hasMcp is true. + * Runs 100 iterations with random combinations of feature flags. + */ +it('Property 7: featureLabel() contains "mcp" when hasMcp is true', function (): void { + for ($i = 0; $i < 100; $i++) { + $package = new ThirdPartyPackage( + name: 'vendor/pkg-'.$i, + hasGuidelines: (bool) random_int(0, 1), + hasSkills: (bool) random_int(0, 1), + hasMcp: true, + ); + + expect($package->featureLabel())->toContain('mcp', + "Iteration {$i}: featureLabel() must contain 'mcp' when hasMcp=true" + ); + } +}); + +/** + * Property 8: discover() includes MCP-only packages (no guidelines or skills). + * Runs 20 iterations (filesystem-based, slower). + */ +it('Property 8: discover() includes packages with only MCP configuration', function (): void { + $packageNames = ['acme/mcp-only-a', 'acme/mcp-only-b', 'vendor-x/mcp-tool']; + + for ($i = 0; $i < 20; $i++) { + // Pick a random subset of MCP-only packages + $count = random_int(1, count($packageNames)); + $selected = array_slice($packageNames, 0, $count); + + $require = array_fill_keys($selected, '^1.0'); + file_put_contents(base_path('composer.json'), json_encode(['require' => $require])); + + foreach ($selected as $pkg) { + $dir = base_path(implode(DIRECTORY_SEPARATOR, [ + 'vendor', str_replace('/', DIRECTORY_SEPARATOR, $pkg), 'resources', 'boost', 'mcp', + ])); + File::ensureDirectoryExists($dir); + file_put_contents($dir.DIRECTORY_SEPARATOR.'mcp.json', json_encode([ + 'servers' => [ + ['name' => 'server-'.str_replace(['/', '-'], '_', $pkg), 'command' => 'node'], + ], + ])); + } + + $packages = ThirdPartyPackage::discover(); + + foreach ($selected as $pkg) { + expect($packages->has($pkg))->toBeTrue( + "Iteration {$i}: MCP-only package {$pkg} must be in discover() results" + ); + expect($packages->get($pkg)->hasMcp)->toBeTrue( + "Iteration {$i}: {$pkg} should have hasMcp=true" + ); + expect($packages->get($pkg)->hasGuidelines)->toBeFalse( + "Iteration {$i}: {$pkg} should have hasGuidelines=false" + ); + expect($packages->get($pkg)->hasSkills)->toBeFalse( + "Iteration {$i}: {$pkg} should have hasSkills=false" + ); + } + + // Cleanup for next iteration + File::deleteDirectory(base_path('vendor')); + unlink(base_path('composer.json')); + } +}); diff --git a/tests/Unit/Install/ThirdPartyPackageTest.php b/tests/Unit/Install/ThirdPartyPackageTest.php index d591e3e5..27eb8da9 100644 --- a/tests/Unit/Install/ThirdPartyPackageTest.php +++ b/tests/Unit/Install/ThirdPartyPackageTest.php @@ -2,6 +2,8 @@ declare(strict_types=1); +use Illuminate\Support\Collection; +use Laravel\Boost\Install\McpServer; use Laravel\Boost\Install\ThirdPartyPackage; it('creates a package with all properties', function (): void { @@ -9,42 +11,75 @@ name: 'vendor/package-name', hasGuidelines: true, hasSkills: true, + hasMcp: true, ); expect($package->name)->toBe('vendor/package-name') ->and($package->hasGuidelines)->toBeTrue() - ->and($package->hasSkills)->toBeTrue(); + ->and($package->hasSkills)->toBeTrue() + ->and($package->hasMcp)->toBeTrue(); }); -it('returns correct feature label', function (bool $hasGuidelines, bool $hasSkills, string $expected): void { +it('defaults hasMcp to false', function (): void { + $package = new ThirdPartyPackage( + name: 'vendor/package', + hasGuidelines: true, + hasSkills: false, + ); + + expect($package->hasMcp)->toBeFalse(); +}); + +it('returns correct feature label', function (bool $hasGuidelines, bool $hasSkills, bool $hasMcp, string $expected): void { $package = new ThirdPartyPackage( name: 'vendor/package', hasGuidelines: $hasGuidelines, hasSkills: $hasSkills, + hasMcp: $hasMcp, ); expect($package->featureLabel())->toBe($expected); })->with([ - 'both features' => [true, true, 'guidelines, skills'], - 'guidelines only' => [true, false, 'guideline'], - 'skills only' => [false, true, 'skills'], - 'no features' => [false, false, ''], + 'all three features' => [true, true, true, 'guidelines, skills, mcp'], + 'guidelines and skills' => [true, true, false, 'guidelines, skills'], + 'guidelines and mcp' => [true, false, true, 'guidelines, mcp'], + 'skills and mcp' => [false, true, true, 'skills, mcp'], + 'guidelines only' => [true, false, false, 'guideline'], + 'skills only' => [false, true, false, 'skills'], + 'mcp only' => [false, false, true, 'mcp'], + 'no features' => [false, false, false, ''], ]); -it('returns correct display label', function (bool $hasGuidelines, bool $hasSkills, string $expected): void { +it('returns correct display label', function (bool $hasGuidelines, bool $hasSkills, bool $hasMcp, string $expected): void { $package = new ThirdPartyPackage( name: 'vendor/package', hasGuidelines: $hasGuidelines, hasSkills: $hasSkills, + hasMcp: $hasMcp, ); expect($package->displayLabel())->toBe($expected); })->with([ - 'both features' => [true, true, 'vendor/package (guidelines, skills)'], - 'guidelines only' => [true, false, 'vendor/package (guideline)'], - 'skills only' => [false, true, 'vendor/package (skills)'], + 'all three features' => [true, true, true, 'vendor/package (guidelines, skills, mcp)'], + 'guidelines and skills' => [true, true, false, 'vendor/package (guidelines, skills)'], + 'guidelines only' => [true, false, false, 'vendor/package (guideline)'], + 'skills only' => [false, true, false, 'vendor/package (skills)'], + 'mcp only' => [false, false, true, 'vendor/package (mcp)'], ]); +it('mcpServers returns empty collection by default', function (): void { + $package = new ThirdPartyPackage(name: 'vendor/pkg', hasGuidelines: false, hasSkills: false); + + expect($package->mcpServers())->toBeInstanceOf(Collection::class) + ->and($package->mcpServers())->toBeEmpty(); +}); + +it('warnings returns empty array by default', function (): void { + $package = new ThirdPartyPackage(name: 'vendor/pkg', hasGuidelines: false, hasSkills: false); + + expect($package->warnings())->toBe([]); +}); + it('excludes first-party packages from discover results', function (): void { $packages = ThirdPartyPackage::discover(); diff --git a/tests/Unit/Support/ComposerPropertyTest.php b/tests/Unit/Support/ComposerPropertyTest.php new file mode 100644 index 00000000..532df638 --- /dev/null +++ b/tests/Unit/Support/ComposerPropertyTest.php @@ -0,0 +1,105 @@ + $require])); + + // Give every package a mcp.json + foreach ($allPackages as $pkg) { + $dir = base_path(implode(DIRECTORY_SEPARATOR, [ + 'vendor', str_replace('/', DIRECTORY_SEPARATOR, $pkg), 'resources', 'boost', 'mcp', + ])); + File::ensureDirectoryExists($dir); + file_put_contents($dir.DIRECTORY_SEPARATOR.'mcp.json', json_encode(['servers' => []])); + } + + $result = Composer::packagesDirectoriesWithBoostMcp(); + + foreach ($firstParty as $fp) { + expect($result)->not->toHaveKey($fp, "Iteration {$i}: first-party package {$fp} must not appear in results"); + } + + // Cleanup for next iteration + File::deleteDirectory(base_path('vendor')); + unlink(base_path('composer.json')); + } +})->skip(fn () => false); + +/** + * Property 2: A package appears in results if and only if its mcp.json is a regular file. + * Runs 100 iterations with random combinations of packages with/without mcp.json. + */ +it('Property 2: package appears in results iff mcp.json exists as a regular file', function (): void { + $candidates = ['acme/alpha', 'acme/beta', 'vendor-x/tool', 'vendor-y/helper', 'org/pkg']; + + for ($i = 0; $i < 100; $i++) { + $withMcp = array_slice($candidates, 0, random_int(0, count($candidates))); + $withoutMcp = array_diff($candidates, $withMcp); + + $require = array_fill_keys($candidates, '^1.0'); + file_put_contents(base_path('composer.json'), json_encode(['require' => $require])); + + // Create vendor dirs for all + foreach ($candidates as $pkg) { + $vendorDir = base_path(implode(DIRECTORY_SEPARATOR, [ + 'vendor', str_replace('/', DIRECTORY_SEPARATOR, $pkg), + ])); + File::ensureDirectoryExists($vendorDir); + } + + // Only give mcp.json to $withMcp packages + foreach ($withMcp as $pkg) { + $dir = base_path(implode(DIRECTORY_SEPARATOR, [ + 'vendor', str_replace('/', DIRECTORY_SEPARATOR, $pkg), 'resources', 'boost', 'mcp', + ])); + File::ensureDirectoryExists($dir); + file_put_contents($dir.DIRECTORY_SEPARATOR.'mcp.json', json_encode(['servers' => []])); + } + + $result = Composer::packagesDirectoriesWithBoostMcp(); + + foreach ($withMcp as $pkg) { + expect($result)->toHaveKey($pkg, "Iteration {$i}: {$pkg} with mcp.json should be in results"); + } + + foreach ($withoutMcp as $pkg) { + expect($result)->not->toHaveKey($pkg, "Iteration {$i}: {$pkg} without mcp.json should not be in results"); + } + + // Cleanup for next iteration + File::deleteDirectory(base_path('vendor')); + unlink(base_path('composer.json')); + } +}); diff --git a/tests/Unit/Support/ComposerTest.php b/tests/Unit/Support/ComposerTest.php new file mode 100644 index 00000000..d8c8d4ec --- /dev/null +++ b/tests/Unit/Support/ComposerTest.php @@ -0,0 +1,173 @@ + [ + 'acme/my-package' => '^1.0', + ], + ])); + + $mcpJsonPath = base_path(implode(DIRECTORY_SEPARATOR, [ + 'vendor', 'acme', 'my-package', 'resources', 'boost', 'mcp', + ])); + File::ensureDirectoryExists($mcpJsonPath); + file_put_contents($mcpJsonPath.DIRECTORY_SEPARATOR.'mcp.json', json_encode(['servers' => []])); + + $result = Composer::packagesDirectoriesWithBoostMcp(); + + expect($result) + ->toHaveKey('acme/my-package') + ->and($result['acme/my-package'])->toEndWith(implode(DIRECTORY_SEPARATOR, [ + 'resources', 'boost', 'mcp', 'mcp.json', + ])); +}); + +it('excludes packages that do not have a mcp.json file', function (): void { + file_put_contents(base_path('composer.json'), json_encode([ + 'require' => [ + 'acme/with-mcp' => '^1.0', + 'acme/without-mcp' => '^1.0', + ], + ])); + + // Package with mcp.json + $mcpJsonPath = base_path(implode(DIRECTORY_SEPARATOR, [ + 'vendor', 'acme', 'with-mcp', 'resources', 'boost', 'mcp', + ])); + File::ensureDirectoryExists($mcpJsonPath); + file_put_contents($mcpJsonPath.DIRECTORY_SEPARATOR.'mcp.json', json_encode(['servers' => []])); + + // Package without mcp.json (directory exists but no file) + $noMcpDir = base_path(implode(DIRECTORY_SEPARATOR, ['vendor', 'acme', 'without-mcp'])); + File::ensureDirectoryExists($noMcpDir); + + $result = Composer::packagesDirectoriesWithBoostMcp(); + + expect($result) + ->toHaveKey('acme/with-mcp') + ->not->toHaveKey('acme/without-mcp'); +}); + +it('excludes first-party packages even if they have a mcp.json file', function (): void { + $firstPartyPackage = Composer::FIRST_PARTY_PACKAGES[0]; + + file_put_contents(base_path('composer.json'), json_encode([ + 'require' => [ + $firstPartyPackage => '^1.0', + 'acme/third-party' => '^1.0', + ], + ])); + + // Give the first-party package a mcp.json + $firstPartyVendorPath = str_replace('/', DIRECTORY_SEPARATOR, $firstPartyPackage); + $firstPartyMcpPath = base_path(implode(DIRECTORY_SEPARATOR, [ + 'vendor', $firstPartyVendorPath, 'resources', 'boost', 'mcp', + ])); + File::ensureDirectoryExists($firstPartyMcpPath); + file_put_contents($firstPartyMcpPath.DIRECTORY_SEPARATOR.'mcp.json', json_encode(['servers' => []])); + + // Give the third-party package a mcp.json too + $thirdPartyMcpPath = base_path(implode(DIRECTORY_SEPARATOR, [ + 'vendor', 'acme', 'third-party', 'resources', 'boost', 'mcp', + ])); + File::ensureDirectoryExists($thirdPartyMcpPath); + file_put_contents($thirdPartyMcpPath.DIRECTORY_SEPARATOR.'mcp.json', json_encode(['servers' => []])); + + $result = Composer::packagesDirectoriesWithBoostMcp(); + + expect($result) + ->not->toHaveKey($firstPartyPackage) + ->toHaveKey('acme/third-party'); +}); + +it('excludes all first-party packages from mcp discovery', function (): void { + $require = []; + foreach (Composer::FIRST_PARTY_PACKAGES as $package) { + $require[$package] = '^1.0'; + } + $require['acme/third-party'] = '^1.0'; + + file_put_contents(base_path('composer.json'), json_encode(['require' => $require])); + + // Give every first-party package a mcp.json + foreach (Composer::FIRST_PARTY_PACKAGES as $package) { + $vendorPath = str_replace('/', DIRECTORY_SEPARATOR, $package); + $mcpPath = base_path(implode(DIRECTORY_SEPARATOR, [ + 'vendor', $vendorPath, 'resources', 'boost', 'mcp', + ])); + File::ensureDirectoryExists($mcpPath); + file_put_contents($mcpPath.DIRECTORY_SEPARATOR.'mcp.json', json_encode(['servers' => []])); + } + + // Third-party package with mcp.json + $thirdPartyMcpPath = base_path(implode(DIRECTORY_SEPARATOR, [ + 'vendor', 'acme', 'third-party', 'resources', 'boost', 'mcp', + ])); + File::ensureDirectoryExists($thirdPartyMcpPath); + file_put_contents($thirdPartyMcpPath.DIRECTORY_SEPARATOR.'mcp.json', json_encode(['servers' => []])); + + $result = Composer::packagesDirectoriesWithBoostMcp(); + + foreach (Composer::FIRST_PARTY_PACKAGES as $package) { + expect($result)->not->toHaveKey($package, "First-party package {$package} should be excluded"); + } + + expect($result)->toHaveKey('acme/third-party'); +}); + +it('returns an empty array when no packages have a mcp.json file', function (): void { + file_put_contents(base_path('composer.json'), json_encode([ + 'require' => [ + 'acme/no-mcp' => '^1.0', + ], + ])); + + $vendorDir = base_path(implode(DIRECTORY_SEPARATOR, ['vendor', 'acme', 'no-mcp'])); + File::ensureDirectoryExists($vendorDir); + + $result = Composer::packagesDirectoriesWithBoostMcp(); + + expect($result)->toBe([]); +}); + +it('returns an empty array when composer.json does not exist', function (): void { + $result = Composer::packagesDirectoriesWithBoostMcp(); + + expect($result)->toBe([]); +}); + +it('maps package name to the absolute path of its mcp.json file', function (): void { + file_put_contents(base_path('composer.json'), json_encode([ + 'require' => [ + 'vendor-name/package-name' => '^1.0', + ], + ])); + + $mcpDir = base_path(implode(DIRECTORY_SEPARATOR, [ + 'vendor', 'vendor-name', 'package-name', 'resources', 'boost', 'mcp', + ])); + File::ensureDirectoryExists($mcpDir); + file_put_contents($mcpDir.DIRECTORY_SEPARATOR.'mcp.json', json_encode(['servers' => []])); + + $result = Composer::packagesDirectoriesWithBoostMcp(); + + $expectedPath = base_path(implode(DIRECTORY_SEPARATOR, [ + 'vendor', 'vendor-name', 'package-name', 'resources', 'boost', 'mcp', 'mcp.json', + ])); + + expect($result)->toHaveKey('vendor-name/package-name') + ->and($result['vendor-name/package-name'])->toBe($expectedPath); +}); diff --git a/tests/Unit/Support/ConfigPropertyTest.php b/tests/Unit/Support/ConfigPropertyTest.php new file mode 100644 index 00000000..2cf0d2ac --- /dev/null +++ b/tests/Unit/Support/ConfigPropertyTest.php @@ -0,0 +1,92 @@ +flush(); +}); + +/** + * Property 12: For any array of MCP server key strings, setMcpServers then getMcpServers returns equivalent array. + * Runs 100 iterations with random server key arrays. + */ +it('Property 12: setMcpServers / getMcpServers round-trip', function (): void { + $vendors = ['acme', 'vendor-x', 'org', 'company']; + $packages = ['my-package', 'tool', 'helper', 'sdk']; + $serverNames = ['mcp-server', 'remote-mcp', 'local-tool', 'api-server']; + + for ($i = 0; $i < 100; $i++) { + $config = new Config; + $config->flush(); + + // Generate a random array of server keys + $count = random_int(0, 5); + $keys = []; + for ($j = 0; $j < $count; $j++) { + $vendor = $vendors[array_rand($vendors)]; + $pkg = $packages[array_rand($packages)]; + $server = $serverNames[array_rand($serverNames)]; + $keys[] = "{$vendor}/{$pkg}/{$server}-{$j}"; + } + + $config->setMcpServers($keys); + + $retrieved = $config->getMcpServers(); + + expect($retrieved)->toEqual($keys, "Iteration {$i}: round-trip should return equivalent array"); + } +}); + +/** + * Property 13: setMcpServers does not affect other config keys. + * Runs 100 iterations with random pre-existing config state. + */ +it('Property 13: setMcpServers does not affect other config keys', function (): void { + $agentOptions = [['cursor'], ['copilot'], ['cursor', 'copilot'], []]; + $packageOptions = [['vendor/pkg'], ['acme/tool'], [], ['a/b', 'c/d']]; + + for ($i = 0; $i < 100; $i++) { + $config = new Config; + $config->flush(); + + // Set random initial state + $mcp = (bool) random_int(0, 1); + $sail = (bool) random_int(0, 1); + $nightwatch = (bool) random_int(0, 1); + $guidelines = (bool) random_int(0, 1); + $agents = $agentOptions[array_rand($agentOptions)]; + $packages = $packageOptions[array_rand($packageOptions)]; + + $config->setMcp($mcp); + $config->setSail($sail); + $config->setNightwatchMcp($nightwatch); + $config->setGuidelines($guidelines); + if ($agents !== []) { + $config->setAgents($agents); + } + if ($packages !== []) { + $config->setPackages($packages); + } + + // Now set mcp_servers + $config->setMcpServers(['acme/pkg/server-'.$i]); + + // Verify other keys are unaffected + expect($config->getMcp())->toBe($mcp, "Iteration {$i}: mcp should be unchanged") + ->and($config->getSail())->toBe($sail, "Iteration {$i}: sail should be unchanged") + ->and($config->getNightwatchMcp())->toBe($nightwatch, "Iteration {$i}: nightwatch_mcp should be unchanged") + ->and($config->getGuidelines())->toBe($guidelines, "Iteration {$i}: guidelines should be unchanged"); + + if ($agents !== []) { + expect($config->getAgents())->toEqual($agents, "Iteration {$i}: agents should be unchanged"); + } + if ($packages !== []) { + expect($config->getPackages())->toEqual($packages, "Iteration {$i}: packages should be unchanged"); + } + } +}); diff --git a/tests/Unit/Support/ConfigTest.php b/tests/Unit/Support/ConfigTest.php index 5baf5dd2..e3bf7f3f 100644 --- a/tests/Unit/Support/ConfigTest.php +++ b/tests/Unit/Support/ConfigTest.php @@ -85,3 +85,43 @@ expect($config->getPackages())->toEqual($packages); }); + +it('may store and retrieve mcp servers', function (): void { + $config = new Config; + + expect($config->getMcpServers())->toBeEmpty(); + + $servers = [ + 'acme/my-package/my-server', + 'acme/my-package/my-remote-server', + ]; + + $config->setMcpServers($servers); + + expect($config->getMcpServers())->toEqual($servers); + + $config->setMcpServers([]); + + expect($config->getMcpServers())->toBeEmpty(); +}); + +it('setMcpServers does not affect other config keys', function (): void { + $config = new Config; + + $config->setMcp(true); + $config->setSail(true); + $config->setNightwatchMcp(true); + $config->setGuidelines(true); + $config->setPackages(['vendor/pkg']); + $config->setAgents(['cursor']); + + $config->setMcpServers(['acme/pkg/server']); + + expect($config->getMcp())->toBeTrue() + ->and($config->getSail())->toBeTrue() + ->and($config->getNightwatchMcp())->toBeTrue() + ->and($config->getGuidelines())->toBeTrue() + ->and($config->getPackages())->toEqual(['vendor/pkg']) + ->and($config->getAgents())->toEqual(['cursor']) + ->and($config->getMcpServers())->toEqual(['acme/pkg/server']); +}); From 69416c84e619be278aacb5d6c2fbe55a5d5e2ef5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wilsen=20Hern=C3=A1ndez?= Date: Wed, 18 Mar 2026 15:08:30 -0400 Subject: [PATCH 2/5] Hide mcp-only packages from the guidelines/skills multi-select --- src/Console/InstallCommand.php | 3 ++- .../InstallCommandOrphanedPackagesTest.php | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/Console/InstallCommand.php b/src/Console/InstallCommand.php index c14f548e..8fdc27bf 100644 --- a/src/Console/InstallCommand.php +++ b/src/Console/InstallCommand.php @@ -290,7 +290,8 @@ protected function shouldConfigureNightwatchMcp(): bool */ protected function selectThirdPartyPackages(): Collection { - $packages = ThirdPartyPackage::discover(); + $packages = ThirdPartyPackage::discover() + ->filter(fn (ThirdPartyPackage $pkg): bool => $pkg->hasGuidelines || $pkg->hasSkills); if ($packages->isEmpty()) { return collect(); diff --git a/tests/Feature/Console/InstallCommandOrphanedPackagesTest.php b/tests/Feature/Console/InstallCommandOrphanedPackagesTest.php index f33595f2..df5b8c02 100644 --- a/tests/Feature/Console/InstallCommandOrphanedPackagesTest.php +++ b/tests/Feature/Console/InstallCommandOrphanedPackagesTest.php @@ -38,3 +38,22 @@ expect($result)->toContain('valid-pkg') ->and($result)->not->toContain('orphaned-pkg'); })->skipOnWindows(); + +it('does not show mcp-only packages in guidelines skills package list', function (): void { + $discoveredPackages = collect([ + 'guidelines-pkg' => new ThirdPartyPackage('guidelines-pkg', true, false, false), + 'skills-pkg' => new ThirdPartyPackage('skills-pkg', false, true, false), + 'mixed-pkg' => new ThirdPartyPackage('mixed-pkg', true, true, true), + 'mcp-only-pkg' => new ThirdPartyPackage('mcp-only-pkg', false, false, true), + ]); + + $options = $discoveredPackages + ->filter(fn (ThirdPartyPackage $pkg): bool => $pkg->hasGuidelines || $pkg->hasSkills) + ->mapWithKeys(fn (ThirdPartyPackage $pkg, string $name): array => [ + $name => $pkg->displayLabel(), + ])->toArray(); + + expect(array_keys($options)) + ->toContain('guidelines-pkg', 'skills-pkg', 'mixed-pkg') + ->not->toContain('mcp-only-pkg'); +})->skipOnWindows(); From 1929f907710d76d99c6f064c29053d66fa791c57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wilsen=20Hern=C3=A1ndez?= Date: Wed, 18 Mar 2026 15:11:42 -0400 Subject: [PATCH 3/5] Fix phpstan errors --- src/Install/McpServer.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Install/McpServer.php b/src/Install/McpServer.php index 9d935ee5..ab0bc827 100644 --- a/src/Install/McpServer.php +++ b/src/Install/McpServer.php @@ -59,6 +59,6 @@ public function toConfigArray(): array 'description' => $this->description, ]; - return array_filter($data, fn (mixed $value): bool => $value !== null && $value !== '' && $value !== []); + return array_filter($data, fn (mixed $value): bool => $value !== null && $value !== ''); } } From c0811cc285b2bf01d93392b88a47734906c99982 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wilsen=20Hern=C3=A1ndez?= Date: Wed, 18 Mar 2026 15:19:22 -0400 Subject: [PATCH 4/5] Fix tests expectations --- tests/Unit/Install/McpWriterPropertyTest.php | 9 ++++----- tests/Unit/Install/ThirdPartyPackagePropertyTest.php | 5 ++--- tests/Unit/Support/ComposerPropertyTest.php | 6 ++++-- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/tests/Unit/Install/McpWriterPropertyTest.php b/tests/Unit/Install/McpWriterPropertyTest.php index b77028ac..4d7d82de 100644 --- a/tests/Unit/Install/McpWriterPropertyTest.php +++ b/tests/Unit/Install/McpWriterPropertyTest.php @@ -127,11 +127,10 @@ $caught = $e; } - expect($caught)->not->toBeNull("Iteration {$i}: write() should throw RuntimeException") - ->and($caught)->toBeInstanceOf(RuntimeException::class) - ->and($caught->getMessage())->toContain($name, - "Iteration {$i}: exception message must contain server name '{$name}'" - ); + expect($caught, "Iteration {$i}: write() should throw RuntimeException")->not->toBeNull(); + expect($caught)->toBeInstanceOf(RuntimeException::class); + expect($caught?->getMessage(), "Iteration {$i}: exception message must contain server name '{$name}'") + ->toContain($name); Mockery::close(); } diff --git a/tests/Unit/Install/ThirdPartyPackagePropertyTest.php b/tests/Unit/Install/ThirdPartyPackagePropertyTest.php index ad102588..0345bbad 100644 --- a/tests/Unit/Install/ThirdPartyPackagePropertyTest.php +++ b/tests/Unit/Install/ThirdPartyPackagePropertyTest.php @@ -63,9 +63,8 @@ hasMcp: true, ); - expect($package->featureLabel())->toContain('mcp', - "Iteration {$i}: featureLabel() must contain 'mcp' when hasMcp=true" - ); + expect($package->featureLabel(), "Iteration {$i}: featureLabel() must contain 'mcp' when hasMcp=true") + ->toContain('mcp'); } }); diff --git a/tests/Unit/Support/ComposerPropertyTest.php b/tests/Unit/Support/ComposerPropertyTest.php index 532df638..301f689c 100644 --- a/tests/Unit/Support/ComposerPropertyTest.php +++ b/tests/Unit/Support/ComposerPropertyTest.php @@ -91,11 +91,13 @@ $result = Composer::packagesDirectoriesWithBoostMcp(); foreach ($withMcp as $pkg) { - expect($result)->toHaveKey($pkg, "Iteration {$i}: {$pkg} with mcp.json should be in results"); + expect($result, "Iteration {$i}: {$pkg} with mcp.json should be in results") + ->toHaveKey($pkg); } foreach ($withoutMcp as $pkg) { - expect($result)->not->toHaveKey($pkg, "Iteration {$i}: {$pkg} without mcp.json should not be in results"); + expect($result, "Iteration {$i}: {$pkg} without mcp.json should not be in results") + ->not->toHaveKey($pkg); } // Cleanup for next iteration From 9081f16df12c3b14ccc199f60edae6b5080eb398 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wilsen=20Hern=C3=A1ndez?= Date: Mon, 30 Mar 2026 18:18:07 -0400 Subject: [PATCH 5/5] feat: add boost mcp tests again --- tests/Unit/Support/ComposerTest.php | 102 ++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/tests/Unit/Support/ComposerTest.php b/tests/Unit/Support/ComposerTest.php index 59090e4c..02f2b665 100644 --- a/tests/Unit/Support/ComposerTest.php +++ b/tests/Unit/Support/ComposerTest.php @@ -73,6 +73,108 @@ ->not->toHaveKey('laravel/horizon'); }); +it('maps package name to the absolute path of its mcp.json file', function (): void { + file_put_contents(base_path('composer.json'), json_encode([ + 'require' => [ + 'vendor-name/package-name' => '^1.0', + ], + ])); + + $mcpDir = base_path(implode(DIRECTORY_SEPARATOR, [ + 'vendor', 'vendor-name', 'package-name', 'resources', 'boost', 'mcp', + ])); + File::ensureDirectoryExists($mcpDir); + file_put_contents($mcpDir.DIRECTORY_SEPARATOR.'mcp.json', json_encode(['servers' => []])); + + $result = Composer::packagesDirectoriesWithBoostMcp(); + + $expectedPath = base_path(implode(DIRECTORY_SEPARATOR, [ + 'vendor', 'vendor-name', 'package-name', 'resources', 'boost', 'mcp', 'mcp.json', + ])); + + expect($result) + ->toHaveKey('vendor-name/package-name') + ->and($result['vendor-name/package-name'])->toBe($expectedPath); +}); + +it('excludes packages that do not have a mcp.json file', function (): void { + file_put_contents(base_path('composer.json'), json_encode([ + 'require' => [ + 'acme/with-mcp' => '^1.0', + 'acme/without-mcp' => '^1.0', + ], + ])); + + $mcpJsonPath = base_path(implode(DIRECTORY_SEPARATOR, [ + 'vendor', 'acme', 'with-mcp', 'resources', 'boost', 'mcp', + ])); + File::ensureDirectoryExists($mcpJsonPath); + file_put_contents($mcpJsonPath.DIRECTORY_SEPARATOR.'mcp.json', json_encode(['servers' => []])); + + $noMcpDir = base_path(implode(DIRECTORY_SEPARATOR, ['vendor', 'acme', 'without-mcp'])); + File::ensureDirectoryExists($noMcpDir); + + $result = Composer::packagesDirectoriesWithBoostMcp(); + + expect($result) + ->toHaveKey('acme/with-mcp') + ->not->toHaveKey('acme/without-mcp'); +}); + +it('excludes all first-party packages from mcp discovery', function (): void { + $require = []; + foreach (Composer::FIRST_PARTY_PACKAGES as $package) { + $require[$package] = '^1.0'; + } + $require['acme/third-party'] = '^1.0'; + + file_put_contents(base_path('composer.json'), json_encode(['require' => $require])); + + foreach (Composer::FIRST_PARTY_PACKAGES as $package) { + $vendorPath = str_replace('/', DIRECTORY_SEPARATOR, $package); + $mcpPath = base_path(implode(DIRECTORY_SEPARATOR, [ + 'vendor', $vendorPath, 'resources', 'boost', 'mcp', + ])); + File::ensureDirectoryExists($mcpPath); + file_put_contents($mcpPath.DIRECTORY_SEPARATOR.'mcp.json', json_encode(['servers' => []])); + } + + $thirdPartyMcpPath = base_path(implode(DIRECTORY_SEPARATOR, [ + 'vendor', 'acme', 'third-party', 'resources', 'boost', 'mcp', + ])); + File::ensureDirectoryExists($thirdPartyMcpPath); + file_put_contents($thirdPartyMcpPath.DIRECTORY_SEPARATOR.'mcp.json', json_encode(['servers' => []])); + + $result = Composer::packagesDirectoriesWithBoostMcp(); + + foreach (Composer::FIRST_PARTY_PACKAGES as $package) { + expect($result)->not->toHaveKey($package, "First-party package {$package} should be excluded"); + } + + expect($result)->toHaveKey('acme/third-party'); +}); + +it('returns an empty array when no packages have a mcp.json file', function (): void { + file_put_contents(base_path('composer.json'), json_encode([ + 'require' => [ + 'acme/no-mcp' => '^1.0', + ], + ])); + + $vendorDir = base_path(implode(DIRECTORY_SEPARATOR, ['vendor', 'acme', 'no-mcp'])); + File::ensureDirectoryExists($vendorDir); + + $result = Composer::packagesDirectoriesWithBoostMcp(); + + expect($result)->toBe([]); +}); + +it('returns an empty array when composer.json does not exist', function (): void { + $result = Composer::packagesDirectoriesWithBoostMcp(); + + expect($result)->toBe([]); +}); + it('identifies scoped first party packages', function (): void { expect(Composer::isFirstPartyPackage('laravel/framework'))->toBeTrue() ->and(Composer::isFirstPartyPackage('laravel/fortify'))->toBeTrue()