Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 83 additions & 2 deletions src/Console/InstallCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ class InstallCommand extends Command
/** @var Collection<int, string> */
private Collection $selectedThirdPartyPackages;

/** @var Collection<int, string> */
private Collection $selectedThirdPartyMcpServers;

private string $projectName;

/** @var array<non-empty-string> */
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -214,6 +220,57 @@ protected function configureMcpOptions(): void
}
}

/**
* @return Collection<int, string>
*/
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<string, ThirdPartyPackage>
*/
protected function thirdPartyPackages(): Collection
{
static $packages = null;

if ($packages === null) {
$packages = ThirdPartyPackage::discover();
}

return $packages;
}

protected function shouldConfigureSail(): bool
{
return confirm(
Expand All @@ -237,7 +294,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();
Expand Down Expand Up @@ -410,6 +468,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());
}
}

Expand Down Expand Up @@ -442,14 +501,36 @@ 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.',
headerMessage: 'Installing MCP servers to your selected Agents',
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,
Expand Down
64 changes: 64 additions & 0 deletions src/Install/McpServer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php

declare(strict_types=1);

namespace Laravel\Boost\Install;

use InvalidArgumentException;

class McpServer
{
public function __construct(
public readonly string $name,
public readonly ?string $command = null,
public readonly array $args = [],
public readonly ?string $url = null,
public readonly ?string $type = null,
public readonly array $env = [],
public readonly ?string $description = null,
) {
//
}

/**
* @param array<string, mixed> $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<string, mixed>
*/
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 !== '');
}
}
43 changes: 42 additions & 1 deletion src/Install/McpWriter.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,37 @@

namespace Laravel\Boost\Install;

use Illuminate\Support\Collection;
use Laravel\Boost\Contracts\SupportsMcp;
use RuntimeException;

class McpWriter
{
public const SUCCESS = 0;

/** @var array<int, string> 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<int, McpServer>|null $thirdPartyServers
*/
public function write(?Sail $sail = null, ?Nightwatch $nightwatch = null, ?Collection $thirdPartyServers = null): int
{
$this->installBoostMcp($sail);

if ($nightwatch instanceof Nightwatch) {
$this->installNightwatchMcp($nightwatch);
}

if ($thirdPartyServers !== null) {
$this->installThirdPartyServers($thirdPartyServers);
}

return self::SUCCESS;
}

Expand Down Expand Up @@ -71,4 +82,34 @@ protected function installNightwatchMcp(Nightwatch $nightwatch): void
throw new RuntimeException('Failed to install Nightwatch MCP: could not write configuration');
}
}

/**
* @param Collection<int, McpServer> $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.
}
}
85 changes: 79 additions & 6 deletions src/Install/ThirdPartyPackage.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,24 @@
namespace Laravel\Boost\Install;

use Illuminate\Support\Collection;
use InvalidArgumentException;
use Laravel\Boost\Support\Composer;

class ThirdPartyPackage
{
/** @var Collection<int, McpServer> */
private Collection $mcpServersCollection;

/** @var array<int, string> */
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();
}

/**
Expand All @@ -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<int, McpServer>, 1: array<int, string>}
*/
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<int, McpServer> */
public function mcpServers(): Collection
{
return $this->mcpServersCollection;
}

/** @return array<int, string> */
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 => '',
};
}
Expand Down
Loading
Loading