From 912717bbd61db6087c63e34712df8125478b670b Mon Sep 17 00:00:00 2001 From: Zacharias Creutznacher Date: Tue, 19 Aug 2025 17:27:37 +0200 Subject: [PATCH 01/17] fix: defer InjectBoost middleware registration until app is booted --- src/BoostServiceProvider.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/BoostServiceProvider.php b/src/BoostServiceProvider.php index b799859a..5e0541f1 100644 --- a/src/BoostServiceProvider.php +++ b/src/BoostServiceProvider.php @@ -180,7 +180,13 @@ private static function mapJsTypeToPsr3Level(string $type): string private function hookIntoResponses(Router $router): void { - $router->pushMiddlewareToGroup('web', InjectBoost::class); + // In Kernel-based apps (pre-Laravel 11 bootstrap), middleware groups can be + // defined or overwritten later in the boot cycle, which could remove any + // middleware we push here if we do it too early. Deferring this until the + // application is fully booted ensures our middleware stays appended. + $this->app->booted(function () use ($router) { + $router->pushMiddlewareToGroup('web', InjectBoost::class); + }); } private function shouldRun(): bool From 7ef74d3a636371252b2ddc1bd70ccdd0d1d6bca3 Mon Sep 17 00:00:00 2001 From: Ashley Hindle Date: Wed, 27 Aug 2025 09:34:53 +0100 Subject: [PATCH 02/17] feat: add robust MCP file configuration writer Adds FileWriter class that 'intelligently' handles plain JSON files, and JSON5 style files which VS Code supports. - Detects & preserves JSON 5 features (comments, trailing commas) - 'Smart' injects based on existing keys and indents --- .../CodeEnvironment/CodeEnvironment.php | 21 +- src/Install/Mcp/FileWriter.php | 384 ++++++++++++ tests/Pest.php | 7 +- tests/TestCase.php | 4 - .../CodeEnvironment/CodeEnvironmentTest.php | 88 ++- tests/Unit/Install/Mcp/FileWriterTest.php | 574 ++++++++++++++++++ tests/fixtures/mcp-comments-in-strings.json | 10 + tests/fixtures/mcp-empty-configkey.json5 | 11 + tests/fixtures/mcp-empty.json | 1 + tests/fixtures/mcp-expected.json5 | 67 ++ tests/fixtures/mcp-no-configkey.json5 | 14 + tests/fixtures/mcp-plain.json | 7 + tests/fixtures/mcp-trailing-comma.json5 | 9 + tests/fixtures/mcp-with-servers.json | 9 + tests/fixtures/mcp.json5 | 57 ++ 15 files changed, 1219 insertions(+), 44 deletions(-) create mode 100644 src/Install/Mcp/FileWriter.php create mode 100644 tests/Unit/Install/Mcp/FileWriterTest.php create mode 100644 tests/fixtures/mcp-comments-in-strings.json create mode 100644 tests/fixtures/mcp-empty-configkey.json5 create mode 100644 tests/fixtures/mcp-empty.json create mode 100644 tests/fixtures/mcp-expected.json5 create mode 100644 tests/fixtures/mcp-no-configkey.json5 create mode 100644 tests/fixtures/mcp-plain.json create mode 100644 tests/fixtures/mcp-trailing-comma.json5 create mode 100644 tests/fixtures/mcp-with-servers.json create mode 100644 tests/fixtures/mcp.json5 diff --git a/src/Install/CodeEnvironment/CodeEnvironment.php b/src/Install/CodeEnvironment/CodeEnvironment.php index 88876193..2a46aea6 100644 --- a/src/Install/CodeEnvironment/CodeEnvironment.php +++ b/src/Install/CodeEnvironment/CodeEnvironment.php @@ -12,6 +12,7 @@ use Laravel\Boost\Install\Detection\DetectionStrategyFactory; use Laravel\Boost\Install\Enums\McpInstallationStrategy; use Laravel\Boost\Install\Enums\Platform; +use Laravel\Boost\Install\Mcp\FileWriter; abstract class CodeEnvironment { @@ -186,21 +187,9 @@ protected function installFileMcp(string $key, string $command, array $args = [] return false; } - File::ensureDirectoryExists(dirname($path)); - - $config = File::exists($path) - ? json_decode(File::get($path), true) ?: [] - : []; - - $mcpKey = $this->mcpConfigKey(); - data_set($config, "{$mcpKey}.{$key}", collect([ - 'command' => $command, - 'args' => $args, - 'env' => $env, - ])->filter()->toArray()); - - $json = json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - - return $json && File::put($path, $json); + return (new FileWriter($path)) + ->configKey($this->mcpConfigKey()) + ->addServer($key, $command, $args, $env) + ->save(); } } diff --git a/src/Install/Mcp/FileWriter.php b/src/Install/Mcp/FileWriter.php new file mode 100644 index 00000000..a0e35ec4 --- /dev/null +++ b/src/Install/Mcp/FileWriter.php @@ -0,0 +1,384 @@ +filePath = $filePath; + } + + public function configKey(string $key): self + { + $this->configKey = $key; + + return $this; + } + + /** + * Add a new MCP server to the configuration while preserving JSON5 formatting. + * + * @param array $args + * @param array $env + */ + public function addServer(string $key, string $command, array $args = [], array $env = []): self + { + $this->serversToAdd[$key] = collect([ + 'command' => $command, + 'args' => $args, + 'env' => $env, + ])->filter()->toArray(); + + return $this; + } + + public function save(): bool + { + $this->ensureDirectoryExists(); + + if ($this->shouldWriteNew()) { + return $this->createNewFile(); + } + + $content = $this->readFile(); + + if ($this->isPlainJson($content)) { + return $this->updatePlainJsonFile($content); + } + + return $this->updateJson5File($content); + } + + protected function updatePlainJsonFile(string $content): bool + { + $config = json_decode($content, true); + if ($config === null) { + return false; + } + + $this->addServersToConfig($config); + + return $this->writeJsonConfig($config); + } + + protected function updateJson5File(string $content): bool + { + $configKeyPattern = '/["\']'.preg_quote($this->configKey, '/').'["\']\\s*:\\s*\\{/'; + + if (preg_match($configKeyPattern, $content, $matches, PREG_OFFSET_CAPTURE)) { + return $this->injectIntoExistingConfigKey($content, $matches); + } else { + return $this->injectNewConfigKey($content); + } + } + + protected function injectIntoExistingConfigKey(string $content, array $matches): bool + { + // $matches[0][1] contains the position of the configKey pattern match + $configKeyStart = $matches[0][1]; + + // Find the opening brace of the configKey object + $openBracePos = strpos($content, '{', $configKeyStart); + if ($openBracePos === false) { + return false; + } + + // Find the matching closing brace for this configKey object + $closeBracePos = $this->findMatchingClosingBrace($content, $openBracePos); + if ($closeBracePos === false) { + return false; + } + + // Detect indentation from surrounding content + $indentLength = $this->detectIndentation($content, $closeBracePos); + + $serverJsonParts = []; + foreach ($this->serversToAdd as $key => $serverConfig) { + $serverJsonParts[] = $this->generateServerJson($key, $serverConfig, $indentLength); + } + $serversJson = implode(','."\n", $serverJsonParts); + + // Check if we need a comma and add it to the preceding content + $needsComma = $this->needsCommaBeforeClosingBrace($content, $openBracePos, $closeBracePos); + if (! $needsComma) { + $newContent = substr_replace($content, $serversJson, $closeBracePos, 0); + + return $this->writeFile($newContent); + } + + // Find the position to add comma (after the last meaningful character) + $commaPosition = $this->findCommaInsertionPoint($content, $openBracePos, $closeBracePos); + if ($commaPosition !== -1) { + $newContent = substr_replace($content, ',', $commaPosition, 0); + $newContent = substr_replace($newContent, $serversJson, $commaPosition + 1, 0); + } else { + $newContent = substr_replace($content, $serversJson, $closeBracePos, 0); + } + + return $this->writeFile($newContent); + } + + protected function injectNewConfigKey(string $content): bool + { + $openBracePos = strpos($content, '{'); + if ($openBracePos === false) { + return false; + } + + $serverJsonParts = []; + foreach ($this->serversToAdd as $key => $serverConfig) { + $serverJsonParts[] = $this->generateServerJson($key, $serverConfig); + } + + $serversJson = implode(',', $serverJsonParts); + $configKeySection = '"'.$this->configKey.'": {'.$serversJson.'}'; + + $needsComma = $this->needsCommaAfterBrace($content, $openBracePos); + $injection = $configKeySection.($needsComma ? ',' : ''); + + $newContent = substr_replace($content, $injection, $openBracePos + 1, 0); + + return $this->writeFile($newContent); + } + + protected function generateServerJson(string $key, array $serverConfig, int $baseIndent = 8): string + { + $json = json_encode($serverConfig, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + + // If no indentation needed, return as-is + if (empty($baseIndent)) { + return '"'.$key.'": '.$json; + } + + // Apply indentation to each line of the JSON + $lines = explode("\n", $json); + $indentedLines = []; + $baseIndent = str_repeat(' ', $baseIndent); + + foreach ($lines as $i => $line) { + if ($i === 0) { + $indentedLines[] = $baseIndent.'"'.$key.'": '.$line; + } else { + $indentedLines[] = $baseIndent.$line; + } + } + + return "\n".implode("\n", $indentedLines); + } + + protected function needsCommaAfterBrace(string $content, int $bracePosition): bool + { + $afterBrace = substr($content, $bracePosition + 1); + $trimmed = preg_replace('/^\s*(?:\/\/.*$)?/m', '', $afterBrace); + + // If next char is } (empty object) or nothing, no comma needed + return ! empty($trimmed) && $trimmed[0] !== '}'; + } + + protected function findMatchingClosingBrace(string $content, int $openBracePos): int|false + { + $braceCount = 1; + $length = strlen($content); + $inString = false; + $escaped = false; + + for ($i = $openBracePos + 1; $i < $length; $i++) { + $char = $content[$i]; + + if (! $inString) { + if ($char === '{') { + $braceCount++; + } elseif ($char === '}') { + $braceCount--; + if ($braceCount === 0) { + return $i; + } + } + } + + // Handle string detection (similar to hasUnquotedComments logic) + if ($char === '"' && ! $escaped) { + $inString = ! $inString; + } + + $escaped = ($char === '\\' && ! $escaped); + } + + return false; + } + + protected function needsCommaBeforeClosingBrace(string $content, int $openBracePos, int $closeBracePos): bool + { + // Get content between opening and closing braces + $innerContent = substr($content, $openBracePos + 1, $closeBracePos - $openBracePos - 1); + + // Skip whitespace and comments to find last meaningful character + $trimmed = preg_replace('/\s+|\/\/.*$/m', '', $innerContent); + + // If empty or ends with opening brace, no comma needed + if (empty($trimmed) || substr($trimmed, -1) === '{') { + return false; + } + + // If ends with comma, no additional comma needed + if (substr($trimmed, -1) === ',') { + return false; + } + + // Otherwise, we need a comma + return true; + } + + protected function findCommaInsertionPoint(string $content, int $openBracePos, int $closeBracePos): int + { + // Work backwards from closing brace to find last meaningful character + for ($i = $closeBracePos - 1; $i > $openBracePos; $i--) { + $char = $content[$i]; + + // Skip whitespace and newlines + if (in_array($char, [' ', "\t", "\n", "\r"])) { + continue; + } + + // Skip comments (simple approach - if we hit //, skip to start of line) + if ($i > 0 && $content[$i - 1] === '/' && $char === '/') { + // Find start of this line + $lineStart = strrpos($content, "\n", $i - strlen($content)) ?: 0; + $i = $lineStart; + continue; + } + + // Found last meaningful character, comma goes after it + if ($char !== ',') { + return $i + 1; + } else { + // Already has comma, no insertion needed + return -1; + } + } + + // Fallback - insert right after opening brace + return $openBracePos + 1; + } + + public function detectIndentation(string $content, int $nearPosition): int + { + // Look backwards from the position to find server-level indentation + // We want to find lines that look like: "server-name": { + + $lines = explode("\n", substr($content, 0, $nearPosition)); + + // Look for the most recent server definition to match its indentation + for ($i = count($lines) - 1; $i >= 0; $i--) { + $line = $lines[$i]; + // Match server definitions: any amount of whitespace + "key": { + if (preg_match('/^(\s*)"[^"]+"\s*:\s*\{/', $line, $matches)) { + return strlen($matches[1]); + } + } + + // Fallback: assume 8 spaces (2 levels of 4-space indentation typical for JSON) + return 8; + } + + /** + * Is the file content plain JSON, without JSON5 features? + */ + protected function isPlainJson(string $content): bool + { + if ($this->hasUnquotedComments($content)) { + return false; + } + + // Trailing commas (,] or ,}) - supported in JSON 5 + if (preg_match('/,\s*[\]}]/', $content)) { + return false; + } + + // Unquoted keys - supported in JSON 5 + if (preg_match('/^\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*:/m', $content)) { + return false; + } + + json_decode($content); + + return json_last_error() === JSON_ERROR_NONE; + } + + protected function hasUnquotedComments(string $content): bool + { + // Regex that matches both quoted strings and comments + // Group 1: Double-quoted strings with escaped characters + // Group 2: Line comments starting with // + $pattern = '/"(\\\\.|[^"\\\\])*"|(\/\/.*)/'; + + if (preg_match_all($pattern, $content, $matches, PREG_SET_ORDER)) { + foreach ($matches as $match) { + // If group 2 is set, we found a // comment outside strings + if (! empty($match[2])) { + return true; + } + } + } + + return false; + } + + protected function createNewFile(): bool + { + $config = []; + $this->addServersToConfig($config); + + return $this->writeJsonConfig($config); + } + + protected function addServersToConfig(array &$config): void + { + foreach ($this->serversToAdd as $key => $serverConfig) { + data_set($config, $this->configKey.'.'.$key, $serverConfig); + } + } + + protected function writeJsonConfig(array $config): bool + { + $json = json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + + return $json && $this->writeFile($json); + } + + protected function ensureDirectoryExists(): void + { + File::ensureDirectoryExists(dirname($this->filePath)); + } + + protected function fileExists(): bool + { + return File::exists($this->filePath); + } + + protected function shouldWriteNew(): bool + { + return ! $this->fileExists() || File::size($this->filePath) < 3; // To account for files that are just `{}` + } + + protected function readFile(): string + { + return File::get($this->filePath); + } + + protected function writeFile(string $content): bool + { + return File::put($this->filePath, $content) !== false; + } +} diff --git a/tests/Pest.php b/tests/Pest.php index e1914f05..b5f85e2d 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -26,6 +26,7 @@ | */ -expect()->extend('toBeOne', function () { - return $this->toBe(1); -}); +function fixture(string $name): string +{ + return file_get_contents(\Pest\testDirectory('fixtures/'.$name)); +} diff --git a/tests/TestCase.php b/tests/TestCase.php index ca1769c7..5d5bbe67 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -4,7 +4,6 @@ namespace Tests; -use Illuminate\Support\Facades\Artisan; use Laravel\Boost\BoostServiceProvider; use Laravel\Mcp\Server\Registrar; use Orchestra\Testbench\TestCase as OrchestraTestCase; @@ -13,11 +12,8 @@ abstract class TestCase extends OrchestraTestCase { protected function defineEnvironment($app) { - // Set environment to local so commands are registered $app['env'] = 'local'; - Artisan::call('vendor:publish', ['--tag' => 'boost-assets']); - $app->singleton('mcp', Registrar::class); } diff --git a/tests/Unit/Install/CodeEnvironment/CodeEnvironmentTest.php b/tests/Unit/Install/CodeEnvironment/CodeEnvironmentTest.php index 3dc56db8..5e3bca84 100644 --- a/tests/Unit/Install/CodeEnvironment/CodeEnvironmentTest.php +++ b/tests/Unit/Install/CodeEnvironment/CodeEnvironmentTest.php @@ -9,6 +9,7 @@ use Laravel\Boost\Contracts\Agent; use Laravel\Boost\Contracts\McpClient; use Laravel\Boost\Install\CodeEnvironment\CodeEnvironment; +use Laravel\Boost\Install\CodeEnvironment\VSCode; use Laravel\Boost\Install\Contracts\DetectionStrategy; use Laravel\Boost\Install\Detection\DetectionStrategyFactory; use Laravel\Boost\Install\Enums\McpInstallationStrategy; @@ -64,19 +65,6 @@ public function mcpConfigPath(): string } } -class TestAgentAndMcpClient extends TestCodeEnvironment implements Agent, McpClient -{ - public function guidelinesPath(): string - { - return 'test-guidelines.md'; - } - - public function mcpConfigPath(): string - { - return '.test/mcp.json'; - } -} - test('detectOnSystem delegates to strategy factory and detection strategy', function () { $platform = Platform::Darwin; $config = ['paths' => ['/test/path']]; @@ -303,6 +291,22 @@ public function mcpConfigPath(): string test('installFileMcp creates new config file when none exists', function () { $environment = Mockery::mock(TestMcpClient::class)->makePartial(); $environment->shouldAllowMockingProtectedMethods(); + $capturedContent = ''; + $expectedContent = <<<'JSON' +{ + "mcpServers": { + "test-key": { + "command": "test-command", + "args": [ + "arg1" + ], + "env": { + "ENV": "value" + } + } + } +} +JSON; $environment->shouldReceive('mcpInstallationStrategy') ->andReturn(McpInstallationStrategy::FILE); @@ -318,17 +322,21 @@ public function mcpConfigPath(): string File::shouldReceive('put') ->once() - ->with('.test/mcp.json', Mockery::type('string')) + ->with(Mockery::capture($capturedPath), Mockery::capture($capturedContent)) ->andReturn(true); $result = $environment->installMcp('test-key', 'test-command', ['arg1'], ['ENV' => 'value']); expect($result)->toBe(true); + expect($capturedPath)->toBe($environment->mcpConfigPath()); + expect($capturedContent)->toBe($expectedContent); }); test('installFileMcp updates existing config file', function () { $environment = Mockery::mock(TestMcpClient::class)->makePartial(); $environment->shouldAllowMockingProtectedMethods(); + $capturedPath = ''; + $capturedContent = ''; $environment->shouldReceive('mcpInstallationStrategy') ->andReturn(McpInstallationStrategy::FILE); @@ -339,6 +347,8 @@ public function mcpConfigPath(): string ->once() ->with('.test'); + File::shouldReceive('size')->once()->andReturn(10); + File::shouldReceive('exists') ->once() ->with('.test/mcp.json') @@ -351,15 +361,51 @@ public function mcpConfigPath(): string File::shouldReceive('put') ->once() - ->with('.test/mcp.json', Mockery::on(function ($json) { - $config = json_decode($json, true); - - return isset($config['mcpServers']['test-key']) && - isset($config['mcpServers']['existing']); - })) + ->with(Mockery::capture($capturedPath), Mockery::capture($capturedContent)) ->andReturn(true); $result = $environment->installMcp('test-key', 'test-command', ['arg1'], ['ENV' => 'value']); - expect($result)->toBe(true); + expect($result)->toBe(true) + ->and($capturedContent) + ->json() + ->toMatchArray([ + 'mcpServers' => [ + 'existing' => [ + 'command' => 'existing-cmd', + ], + 'test-key' => [ + 'command' => 'test-command', + 'args' => ['arg1'], + 'env' => ['ENV' => 'value'], + ], + ], + ]); + +}); + +test('installFileMcp works with existing config file using JSON 5', function () { + $vscode = new VSCode($this->strategyFactory); + $capturedPath = ''; + $capturedContent = ''; + $json5 = fixture('mcp.json5'); + + File::shouldReceive('exists')->once()->andReturn(true); + File::shouldReceive('size')->once()->andReturn(10); + File::shouldReceive('put') + ->with( + Mockery::capture($capturedPath), + Mockery::capture($capturedContent), + ) + ->andReturn(true); + + File::shouldReceive('get') + ->with($vscode->mcpConfigPath()) + ->andReturn($json5)->getMock()->shouldIgnoreMissing(); + + $wasWritten = $vscode->installMcp('boost', 'php', ['artisan', 'boost:mcp'], ['SITE_PATH' => '/tmp/']); + + expect($wasWritten)->toBeTrue() + ->and($capturedPath)->toBe($vscode->mcpConfigPath()) + ->and($capturedContent)->toBe(fixture('mcp-expected.json5')); }); diff --git a/tests/Unit/Install/Mcp/FileWriterTest.php b/tests/Unit/Install/Mcp/FileWriterTest.php new file mode 100644 index 00000000..aa22e7d0 --- /dev/null +++ b/tests/Unit/Install/Mcp/FileWriterTest.php @@ -0,0 +1,574 @@ +toBeInstanceOf(FileWriter::class); +}); + +test('configKey method returns self for chaining', function () { + $writer = new FileWriter('/path/to/mcp.json'); + $result = $writer->configKey('customKey'); + + expect($result)->toBe($writer); +}); + +test('addServer method returns self for chaining', function () { + $writer = new FileWriter('/path/to/mcp.json'); + $result = $writer + ->configKey('servers') + ->addServer('test', 'php', ['artisan'], ['ENV' => 'value']); + + expect($result)->toBe($writer); +}); + +test('save method returns boolean', function () { + mockFileOperations(); + $writer = new FileWriter('/path/to/mcp.json'); + $result = $writer->save(); + + expect($result)->toBe(true); +}); + +test('written data is correct for brand new file', function (string $configKey, array $servers, string $expectedJson) { + $writtenPath = ''; + $writtenContent = ''; + mockFileOperations(capturedPath: $writtenPath, capturedContent: $writtenContent); + + $writer = (new FileWriter('/path/to/mcp.json')) + ->configKey($configKey); + + foreach ($servers as $serverKey => $serverConfig) { + $writer->addServer( + $serverKey, + $serverConfig['command'], + $serverConfig['args'] ?? [], + $serverConfig['env'] ?? [] + ); + } + + $result = $writer->save(); + + $simpleContents = Str::of($writtenContent)->replaceMatches('/\s+/', ''); + expect($result)->toBe(true); + expect($simpleContents)->toEqual($expectedJson); +})->with(newFileServerConfigurations()); + +test('updates existing plain JSON file using simple method', function () { + $writtenPath = ''; + $writtenContent = ''; + + mockFileOperations( + fileExists: true, + content: fixture('mcp-plain.json'), + capturedPath: $writtenPath, + capturedContent: $writtenContent + ); + + // Need to mock File::size for fileEmpty check + File::shouldReceive('size')->andReturn(100); + + $result = (new FileWriter('/path/to/mcp.json')) + ->configKey('servers') + ->addServer('new-server', 'npm', ['start']) + ->save(); + + expect($result)->toBeTrue(); + + $decoded = json_decode($writtenContent, true); + expect($decoded)->toHaveKey('existing'); + expect($decoded)->toHaveKey('other'); + expect($decoded)->toHaveKey('nested.key'); // From fixture + expect($decoded)->toHaveKey('servers.new-server'); + expect($decoded['servers']['new-server']['command'])->toBe('npm'); +}); + +test('adds to existing mcpServers in plain JSON', function () { + $writtenPath = ''; + $writtenContent = ''; + + mockFileOperations( + fileExists: true, + content: fixture('mcp-with-servers.json'), + capturedPath: $writtenPath, + capturedContent: $writtenContent + ); + + File::shouldReceive('size')->andReturn(200); + + $result = (new FileWriter('/path/to/mcp.json')) + ->addServer('boost', 'php', ['artisan', 'boost:mcp']) + ->save(); + + expect($result)->toBeTrue(); + + $decoded = json_decode($writtenContent, true); + + expect($decoded)->toHaveKey('mcpServers.existing-server'); // Original preserved + expect($decoded)->toHaveKey('mcpServers.boost'); // New server added + expect($decoded['mcpServers']['boost']['command'])->toBe('php'); +}); + +test('preserves complex JSON5 features that VS Code supports', function () { + $writtenContent = ''; + + mockFileOperations( + fileExists: true, + content: fixture('mcp.json5'), + capturedContent: $writtenContent + ); + + File::shouldReceive('size')->andReturn(1000); + + $result = (new FileWriter('/path/to/mcp.json')) + ->configKey('servers') // mcp.json5 uses "servers", not "mcpServers" + ->addServer('test', 'cmd') + ->save(); + + expect($result)->toBeTrue(); + expect($writtenContent)->toContain('"test"'); // New server added + expect($writtenContent)->toContain('// Here are comments within my JSON'); // Preserve block comments + expect($writtenContent)->toContain("// I'm trailing"); // Preserve inline comments + expect($writtenContent)->toContain('// Ooo, pretty cool'); // Preserve comments in arrays + expect($writtenContent)->toContain('MYSQL_HOST'); // Preserve complex nested structure +}); + +test('detects plain JSON with comments inside strings as safe', function () { + $writtenContent = ''; + + mockFileOperations( + fileExists: true, + content: fixture('mcp-comments-in-strings.json'), + capturedContent: $writtenContent + ); + + File::shouldReceive('size')->andReturn(200); + + $result = (new FileWriter('/path/to/mcp.json')) + ->addServer('new-server', 'test-cmd') + ->save(); + + expect($result)->toBeTrue(); + + $decoded = json_decode($writtenContent, true); + expect($decoded)->toHaveKey('exampleCode'); // Original preserved + expect($decoded)->toHaveKey('mcpServers.new-server'); // New server added + expect($decoded['exampleCode'])->toContain('// here is the example code'); // Comment in string preserved +}); + +test('hasUnquotedComments detects comments correctly', function (string $content, bool $expected, string $description) { + $writer = new FileWriter('/tmp/test.json'); + $reflection = new \ReflectionClass($writer); + $method = $reflection->getMethod('hasUnquotedComments'); + $method->setAccessible(true); + + $result = $method->invokeArgs($writer, [$content]); + + expect($result)->toBe($expected, $description); +})->with(commentDetectionCases()); + +test('trailing comma detection works across newlines', function (string $content, bool $expected, string $description) { + $writer = new FileWriter('/tmp/test.json'); + $reflection = new \ReflectionClass($writer); + $method = $reflection->getMethod('isPlainJson'); + $method->setAccessible(true); + + $result = $method->invokeArgs($writer, [$content]); + + expect($result)->toBe($expected, $description); +})->with(trailingCommaCases()); + +test('generateServerJson creates correct JSON snippet', function () { + $writer = new FileWriter('/tmp/test.json'); + $reflection = new \ReflectionClass($writer); + $method = $reflection->getMethod('generateServerJson'); + $method->setAccessible(true); + + // Test with simple server + $result = $method->invokeArgs($writer, ['boost', ['command' => 'php']]); + expect($result)->toBe('"boost": { + "command": "php" +}'); + + // Test with full server config + $result = $method->invokeArgs($writer, ['mysql', [ + 'command' => 'npx', + 'args' => ['@benborla29/mcp-server-mysql'], + 'env' => ['DB_HOST' => 'localhost'], + ]]); + expect($result)->toBe('"mysql": { + "command": "npx", + "args": [ + "@benborla29/mcp-server-mysql" + ], + "env": { + "DB_HOST": "localhost" + } +}'); +}); + +test('fixture mcp-no-configkey.json5 is detected as JSON5 and will use injectNewConfigKey', function () { + $content = fixture('mcp-no-configkey.json5'); + $writer = new FileWriter('/tmp/test.json'); + $reflection = new \ReflectionClass($writer); + + // Verify it's detected as JSON5 (not plain JSON) + $isPlainJsonMethod = $reflection->getMethod('isPlainJson'); + $isPlainJsonMethod->setAccessible(true); + $isPlainJson = $isPlainJsonMethod->invokeArgs($writer, [$content]); + expect($isPlainJson)->toBeFalse('Should be detected as JSON5 due to comments'); + + // Verify it doesn't have mcpServers key (will use injectNewConfigKey path) + $configKeyPattern = '/["\']mcpServers["\']\\s*:\\s*\\{/'; + $hasConfigKey = preg_match($configKeyPattern, $content); + expect($hasConfigKey)->toBe(0, 'Should not have mcpServers key, triggering injectNewConfigKey'); +}); + +test('injects new configKey when it does not exist', function () { + $writtenContent = ''; + + mockFileOperations( + fileExists: true, + content: fixture('mcp-no-configkey.json5'), + capturedContent: $writtenContent + ); + + File::shouldReceive('size')->andReturn(200); + + $result = (new FileWriter('/path/to/mcp.json')) + ->addServer('boost', 'php', ['artisan', 'boost:mcp']) + ->save(); + + expect($result)->toBeTrue(); + expect($writtenContent)->toContain('"mcpServers"'); + expect($writtenContent)->toContain('"boost"'); + expect($writtenContent)->toContain('"php"'); + expect($writtenContent)->toContain('// No mcpServers key at all'); // Preserve existing comments +}); + +test('injects into existing configKey preserving JSON5 features', function () { + $writtenContent = ''; + + mockFileOperations( + fileExists: true, + content: fixture('mcp.json5'), + capturedContent: $writtenContent + ); + + File::shouldReceive('size')->andReturn(1000); + + $result = (new FileWriter('/path/to/mcp.json')) + ->configKey('servers') // mcp.json5 uses "servers" not "mcpServers" + ->addServer('boost', 'php', ['artisan', 'boost:mcp']) + ->save(); + + expect($result)->toBeTrue(); + expect($writtenContent)->toContain('"boost"'); // New server added + expect($writtenContent)->toContain('mysql'); // Existing server preserved + expect($writtenContent)->toContain('laravel-boost'); // Existing server preserved + expect($writtenContent)->toContain('// Here are comments within my JSON'); // Comments preserved + expect($writtenContent)->toContain('// Ooo, pretty cool'); // Inline comments preserved +}); + +test('injects into empty configKey object', function () { + $writtenContent = ''; + + mockFileOperations( + fileExists: true, + content: fixture('mcp-empty-configkey.json5'), + capturedContent: $writtenContent + ); + + File::shouldReceive('size')->andReturn(200); + + $result = (new FileWriter('/path/to/mcp.json')) + ->addServer('boost', 'php', ['artisan', 'boost:mcp']) + ->save(); + + expect($result)->toBeTrue(); + expect($writtenContent)->toContain('"boost"'); // New server added + expect($writtenContent)->toContain('// Empty mcpServers object'); // Comments preserved + expect($writtenContent)->toContain('test_input'); // Existing content preserved +}); + +test('preserves trailing commas when injecting into existing servers', function () { + $writtenContent = ''; + + mockFileOperations( + fileExists: true, + content: fixture('mcp-trailing-comma.json5'), + capturedContent: $writtenContent + ); + + File::shouldReceive('size')->andReturn(200); + + $result = (new FileWriter('/path/to/mcp.json')) + ->addServer('boost', 'php', ['artisan', 'boost:mcp']) + ->save(); + + expect($result)->toBeTrue(); + expect($writtenContent)->toContain('"boost"'); // New server added + expect($writtenContent)->toContain('existing-server'); // Existing server preserved + expect($writtenContent)->toContain('// Trailing comma here'); // Comments preserved + expect($writtenContent)->toContain('arg1'); // Existing args preserved +}); + +test('detectIndentation works correctly with various patterns', function (string $content, int $position, string $expected, string $description) { + $writer = new FileWriter('/tmp/test.json'); + + $result = $writer->detectIndentation($content, $position); + + expect($result)->toBe($expected, $description); +})->with(indentationDetectionCases()); + +function mockFileOperations(bool $fileExists = false, string $content = '{}', bool $writeSuccess = true, ?string &$capturedPath = null, ?string &$capturedContent = null): void +{ + // Clear any existing File facade mock + File::clearResolvedInstances(); + File::partialMock(); + + File::shouldReceive('ensureDirectoryExists')->once(); + File::shouldReceive('exists')->andReturn($fileExists); + + if ($fileExists) { + File::shouldReceive('get')->once()->andReturn($content); + } + + // Check if either capture parameter is provided + if (! is_null($capturedPath) || ! is_null($capturedContent)) { + File::shouldReceive('put') + ->once() + ->with( + Mockery::capture($capturedPath), + Mockery::capture($capturedContent) + ) + ->andReturn($writeSuccess); + } else { + File::shouldReceive('put')->once()->andReturn($writeSuccess); + } +} + +function newFileServerConfigurations(): array +{ + return [ + 'single server without args or env' => [ + 'configKey' => 'servers', + 'servers' => [ + 'im-new-here' => ['command' => './start-mcp'], + ], + 'expectedJson' => '{"servers":{"im-new-here":{"command":"./start-mcp"}}}', + ], + 'single server with args' => [ + 'configKey' => 'mcpServers', + 'servers' => [ + 'boost' => [ + 'command' => 'php', + 'args' => ['artisan', 'boost:mcp'], + ], + ], + 'expectedJson' => '{"mcpServers":{"boost":{"command":"php","args":["artisan","boost:mcp"]}}}', + ], + 'single server with env' => [ + 'configKey' => 'servers', + 'servers' => [ + 'mysql' => [ + 'command' => 'npx', + 'env' => ['DB_HOST' => 'localhost', 'DB_PORT' => '3306'], + ], + ], + 'expectedJson' => '{"servers":{"mysql":{"command":"npx","env":{"DB_HOST":"localhost","DB_PORT":"3306"}}}}', + ], + 'multiple servers mixed' => [ + 'configKey' => 'mcpServers', + 'servers' => [ + 'boost' => [ + 'command' => 'php', + 'args' => ['artisan', 'boost:mcp'], + ], + 'mysql' => [ + 'command' => 'npx', + 'args' => ['@benborla29/mcp-server-mysql'], + 'env' => ['DB_HOST' => 'localhost'], + ], + ], + 'expectedJson' => '{"mcpServers":{"boost":{"command":"php","args":["artisan","boost:mcp"]},"mysql":{"command":"npx","args":["@benborla29/mcp-server-mysql"],"env":{"DB_HOST":"localhost"}}}}', + ], + 'custom config key' => [ + 'configKey' => 'customKey', + 'servers' => [ + 'test' => ['command' => 'test-cmd'], + ], + 'expectedJson' => '{"customKey":{"test":{"command":"test-cmd"}}}', + ], + ]; +} + +function commentDetectionCases(): array +{ + return [ + 'plain JSON no comments' => [ + 'content' => '{"servers": {"test": {"command": "npm"}}}', + 'expected' => false, + 'description' => 'Plain JSON should return false', + ], + 'JSON with comments in strings' => [ + 'content' => '{"exampleCode": "// here is the example code\n false, + 'description' => 'Comments inside strings should not be detected as real comments', + ], + 'JSON5 with real line comments' => [ + 'content' => '{"servers": {"test": "value"} // this is a real comment}', + 'expected' => true, + 'description' => 'Real JSON5 line comments should be detected', + ], + 'JSON5 with comment at start of line' => [ + 'content' => '{\n // This is a comment\n "servers": {}\n}', + 'expected' => true, + 'description' => 'Line comments at start should be detected', + ], + 'complex string with escaped quotes' => [ + 'content' => '{"code": "console.log(\\"// not a comment\\");", "other": "value"}', + 'expected' => false, + 'description' => 'Comments in strings with escaped quotes should not be detected', + ], + 'multiple comments in strings' => [ + 'content' => '{"example1": "// comment 1", "example2": "some // comment 2 here"}', + 'expected' => false, + 'description' => 'Multiple comments in different strings should not be detected', + ], + 'mixed real and string comments' => [ + 'content' => '{"example": "// fake comment"} // real comment', + 'expected' => true, + 'description' => 'Should detect real comment even when fake ones exist in strings', + ], + 'empty string' => [ + 'content' => '', + 'expected' => false, + 'description' => 'Empty string should return false', + ], + 'single slash not comment' => [ + 'content' => '{"path": "/usr/bin/test"}', + 'expected' => false, + 'description' => 'Single slash should not be detected as comment', + ], + ]; +} + +function trailingCommaCases(): array +{ + return [ + 'valid JSON no trailing comma' => [ + 'content' => '{"servers": {"test": "value"}}', + 'expected' => true, + 'description' => 'Valid JSON should return true (is plain JSON)', + ], + 'trailing comma in object same line' => [ + 'content' => '{"servers": {"test": "value",}}', + 'expected' => false, + 'description' => 'Trailing comma in object should return false (is JSON5)', + ], + 'trailing comma in array same line' => [ + 'content' => '{"items": ["a", "b", "c",]}', + 'expected' => false, + 'description' => 'Trailing comma in array should return false (is JSON5)', + ], + 'trailing comma across newlines in object' => [ + 'content' => "{\n \"servers\": {\n \"test\": \"value\",\n }\n}", + 'expected' => false, + 'description' => 'Trailing comma across newlines in object should be detected', + ], + 'trailing comma across newlines in array' => [ + 'content' => "{\n \"items\": [\n \"a\",\n \"b\",\n ]\n}", + 'expected' => false, + 'description' => 'Trailing comma across newlines in array should be detected', + ], + 'trailing comma with tabs and spaces' => [ + 'content' => "{\n \"test\": \"value\",\t \n}", + 'expected' => false, + 'description' => 'Trailing comma with mixed whitespace should be detected', + ], + 'comma in string not trailing' => [ + 'content' => '{"example": "value,", "other": "test"}', + 'expected' => true, + 'description' => 'Comma inside string should not be detected as trailing', + ], + ]; +} + +function indentationDetectionCases(): array +{ + return [ + 'mcp.json5 servers indentation' => [ + 'content' => "{\n // Here are comments within my JSON\n \"servers\": {\n \"mysql\": {\n \"command\": \"npx\"\n },\n \"laravel-boost\": {\n \"command\": \"php\"\n }\n },\n \"inputs\": []\n}", + 'position' => 200, // Position near end of servers block + 'expected' => ' ', // 8 spaces for server-level items + 'description' => 'Should detect 8 spaces for server definitions in mcp.json5', + ], + 'nested object with 4-space base indent' => [ + 'content' => "{\n \"config\": {\n \"server1\": {\n \"command\": \"test\"\n }\n }\n}", + 'position' => 80, + 'expected' => ' ', // 8 spaces for server-level + 'description' => 'Should detect 8 spaces for nested server definitions', + ], + 'no previous server definitions' => [ + 'content' => "{\n \"inputs\": []\n}", + 'position' => 20, + 'expected' => ' ', // Fallback to 8 spaces + 'description' => 'Should fallback to 8 spaces when no server definitions found', + ], + 'tab-indented servers' => [ + 'content' => "{\n\t\"servers\": {\n\t\t\"mysql\": {\n\t\t\t\"command\": \"npx\"\n\t\t}\n\t}\n}", + 'position' => 50, + 'expected' => "\t\t", // 2 tabs for server-level + 'description' => 'Should detect tab indentation for server definitions', + ], + 'mixed indentation with tabs and spaces' => [ + 'content' => "{\n \t\"servers\": {\n \t \"mysql\": {\n \t \"command\": \"npx\"\n \t }\n \t}\n}", + 'position' => 70, + 'expected' => " \t ", // Mixed indentation preserved + 'description' => 'Should preserve mixed tab/space indentation', + ], + 'deeper nesting with 2-space indent' => [ + 'content' => "{\n \"config\": {\n \"servers\": {\n \"mysql\": {\n \"command\": \"test\"\n }\n }\n }\n}", + 'position' => 80, + 'expected' => ' ', // 6 spaces for server-level in deeper nesting + 'description' => 'Should detect correct indentation in deeply nested structures', + ], + 'single server definition at root level' => [ + 'content' => "{\n\"mysql\": {\n \"command\": \"npx\"\n}\n}", + 'position' => 30, + 'expected' => '', // No indentation at root level + 'description' => 'Should detect no indentation for root-level server definitions', + ], + 'multiple server definitions with consistent indentation' => [ + 'content' => "{\n \"servers\": {\n \"mysql\": {\n \"command\": \"npx\"\n },\n \"postgres\": {\n \"command\": \"pg\"\n }\n }\n}", + 'position' => 150, + 'expected' => ' ', // 8 spaces + 'description' => 'Should consistently detect indentation across multiple servers', + ], + 'server definition with comments' => [ + 'content' => "{\n // Comment here\n \"servers\": {\n \"mysql\": { // inline comment\n \"command\": \"npx\"\n }\n }\n}", + 'position' => 120, + 'expected' => ' ', // 8 spaces + 'description' => 'Should detect indentation correctly when comments are present', + ], + 'empty content' => [ + 'content' => '', + 'position' => 0, + 'expected' => ' ', // Fallback + 'description' => 'Should fallback to 8 spaces for empty content', + ], + ]; +} diff --git a/tests/fixtures/mcp-comments-in-strings.json b/tests/fixtures/mcp-comments-in-strings.json new file mode 100644 index 00000000..b1245b68 --- /dev/null +++ b/tests/fixtures/mcp-comments-in-strings.json @@ -0,0 +1,10 @@ +{ + "exampleCode": "// here is the example code\n Date: Wed, 27 Aug 2025 09:41:26 +0100 Subject: [PATCH 03/17] test: fix tests now indentation changed to count of spaces rather than a string --- src/Install/Mcp/FileWriter.php | 2 +- .../Detection/FileDetectionStrategyTest.php | 4 +-- tests/Unit/Install/Mcp/FileWriterTest.php | 30 ++++++------------- 3 files changed, 12 insertions(+), 24 deletions(-) diff --git a/src/Install/Mcp/FileWriter.php b/src/Install/Mcp/FileWriter.php index a0e35ec4..c36bb87c 100644 --- a/src/Install/Mcp/FileWriter.php +++ b/src/Install/Mcp/FileWriter.php @@ -152,7 +152,7 @@ protected function injectNewConfigKey(string $content): bool return $this->writeFile($newContent); } - protected function generateServerJson(string $key, array $serverConfig, int $baseIndent = 8): string + protected function generateServerJson(string $key, array $serverConfig, int $baseIndent = 0): string { $json = json_encode($serverConfig, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); diff --git a/tests/Unit/Install/Detection/FileDetectionStrategyTest.php b/tests/Unit/Install/Detection/FileDetectionStrategyTest.php index db0a5171..cab3b0df 100644 --- a/tests/Unit/Install/Detection/FileDetectionStrategyTest.php +++ b/tests/Unit/Install/Detection/FileDetectionStrategyTest.php @@ -5,13 +5,13 @@ use Laravel\Boost\Install\Detection\FileDetectionStrategy; beforeEach(function () { - $this->strategy = new FileDetectionStrategy(); + $this->strategy = new FileDetectionStrategy; $this->tempDir = sys_get_temp_dir().'/boost_test_'.uniqid(); mkdir($this->tempDir); }); afterEach(function () { - if (is_dir($this->tempDir)) { + if (is_dir($this->tempDir) && str_contains($this->tempDir, sys_get_temp_dir())) { removeDirectoryForFileTests($this->tempDir); } }); diff --git a/tests/Unit/Install/Mcp/FileWriterTest.php b/tests/Unit/Install/Mcp/FileWriterTest.php index aa22e7d0..7f33bf42 100644 --- a/tests/Unit/Install/Mcp/FileWriterTest.php +++ b/tests/Unit/Install/Mcp/FileWriterTest.php @@ -325,7 +325,7 @@ expect($writtenContent)->toContain('arg1'); // Existing args preserved }); -test('detectIndentation works correctly with various patterns', function (string $content, int $position, string $expected, string $description) { +test('detectIndentation works correctly with various patterns', function (string $content, int $position, int $expected, string $description) { $writer = new FileWriter('/tmp/test.json'); $result = $writer->detectIndentation($content, $position); @@ -513,61 +513,49 @@ function indentationDetectionCases(): array 'mcp.json5 servers indentation' => [ 'content' => "{\n // Here are comments within my JSON\n \"servers\": {\n \"mysql\": {\n \"command\": \"npx\"\n },\n \"laravel-boost\": {\n \"command\": \"php\"\n }\n },\n \"inputs\": []\n}", 'position' => 200, // Position near end of servers block - 'expected' => ' ', // 8 spaces for server-level items + 'expected' => 8, 'description' => 'Should detect 8 spaces for server definitions in mcp.json5', ], 'nested object with 4-space base indent' => [ 'content' => "{\n \"config\": {\n \"server1\": {\n \"command\": \"test\"\n }\n }\n}", 'position' => 80, - 'expected' => ' ', // 8 spaces for server-level + 'expected' => 8, 'description' => 'Should detect 8 spaces for nested server definitions', ], 'no previous server definitions' => [ 'content' => "{\n \"inputs\": []\n}", 'position' => 20, - 'expected' => ' ', // Fallback to 8 spaces + 'expected' => 8, 'description' => 'Should fallback to 8 spaces when no server definitions found', ], - 'tab-indented servers' => [ - 'content' => "{\n\t\"servers\": {\n\t\t\"mysql\": {\n\t\t\t\"command\": \"npx\"\n\t\t}\n\t}\n}", - 'position' => 50, - 'expected' => "\t\t", // 2 tabs for server-level - 'description' => 'Should detect tab indentation for server definitions', - ], - 'mixed indentation with tabs and spaces' => [ - 'content' => "{\n \t\"servers\": {\n \t \"mysql\": {\n \t \"command\": \"npx\"\n \t }\n \t}\n}", - 'position' => 70, - 'expected' => " \t ", // Mixed indentation preserved - 'description' => 'Should preserve mixed tab/space indentation', - ], 'deeper nesting with 2-space indent' => [ 'content' => "{\n \"config\": {\n \"servers\": {\n \"mysql\": {\n \"command\": \"test\"\n }\n }\n }\n}", 'position' => 80, - 'expected' => ' ', // 6 spaces for server-level in deeper nesting + 'expected' => 6, 'description' => 'Should detect correct indentation in deeply nested structures', ], 'single server definition at root level' => [ 'content' => "{\n\"mysql\": {\n \"command\": \"npx\"\n}\n}", 'position' => 30, - 'expected' => '', // No indentation at root level + 'expected' => 0, 'description' => 'Should detect no indentation for root-level server definitions', ], 'multiple server definitions with consistent indentation' => [ 'content' => "{\n \"servers\": {\n \"mysql\": {\n \"command\": \"npx\"\n },\n \"postgres\": {\n \"command\": \"pg\"\n }\n }\n}", 'position' => 150, - 'expected' => ' ', // 8 spaces + 'expected' => 8, 'description' => 'Should consistently detect indentation across multiple servers', ], 'server definition with comments' => [ 'content' => "{\n // Comment here\n \"servers\": {\n \"mysql\": { // inline comment\n \"command\": \"npx\"\n }\n }\n}", 'position' => 120, - 'expected' => ' ', // 8 spaces + 'expected' => 8, 'description' => 'Should detect indentation correctly when comments are present', ], 'empty content' => [ 'content' => '', 'position' => 0, - 'expected' => ' ', // Fallback + 'expected' => 8, 'description' => 'Should fallback to 8 spaces for empty content', ], ]; From 283ee399717133ef646ee0b1094853f1d5143f05 Mon Sep 17 00:00:00 2001 From: Ashley Hindle Date: Wed, 27 Aug 2025 18:10:28 +0100 Subject: [PATCH 04/17] feat: improve indentation lines --- src/Install/Mcp/FileWriter.php | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/src/Install/Mcp/FileWriter.php b/src/Install/Mcp/FileWriter.php index c36bb87c..4789b178 100644 --- a/src/Install/Mcp/FileWriter.php +++ b/src/Install/Mcp/FileWriter.php @@ -162,17 +162,13 @@ protected function generateServerJson(string $key, array $serverConfig, int $bas } // Apply indentation to each line of the JSON - $lines = explode("\n", $json); - $indentedLines = []; $baseIndent = str_repeat(' ', $baseIndent); - - foreach ($lines as $i => $line) { - if ($i === 0) { - $indentedLines[] = $baseIndent.'"'.$key.'": '.$line; - } else { - $indentedLines[] = $baseIndent.$line; - } - } + $lines = explode("\n", $json); + $firstLine = array_shift($lines); + $indentedLines = [ + "{$baseIndent}\"{$key}\": {$firstLine}", + ...array_map(fn ($line) => $baseIndent.$line, $lines), + ]; return "\n".implode("\n", $indentedLines); } From 96933546a90fb2c69a9e53498a66703ba39159ce Mon Sep 17 00:00:00 2001 From: Ashley Hindle Date: Wed, 27 Aug 2025 18:23:44 +0100 Subject: [PATCH 05/17] feat: improve json reading error handling --- src/Install/Mcp/FileWriter.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Install/Mcp/FileWriter.php b/src/Install/Mcp/FileWriter.php index 4789b178..b2db2eda 100644 --- a/src/Install/Mcp/FileWriter.php +++ b/src/Install/Mcp/FileWriter.php @@ -5,6 +5,7 @@ namespace Laravel\Boost\Install\Mcp; use Illuminate\Support\Facades\File; +use Illuminate\Support\Str; class FileWriter { @@ -63,7 +64,7 @@ public function save(): bool protected function updatePlainJsonFile(string $content): bool { $config = json_decode($content, true); - if ($config === null) { + if (json_last_error() !== JSON_ERROR_NONE) { return false; } @@ -223,16 +224,15 @@ protected function needsCommaBeforeClosingBrace(string $content, int $openBraceP $trimmed = preg_replace('/\s+|\/\/.*$/m', '', $innerContent); // If empty or ends with opening brace, no comma needed - if (empty($trimmed) || substr($trimmed, -1) === '{') { + if (blank($trimmed) || Str::endsWith($trimmed, '{')) { return false; } // If ends with comma, no additional comma needed - if (substr($trimmed, -1) === ',') { + if (Str::endsWith($trimmed, ',')) { return false; } - // Otherwise, we need a comma return true; } From ab60b26e3a4d8554339abb5aaa6a8793e0b6c4c5 Mon Sep 17 00:00:00 2001 From: Ashley Hindle Date: Wed, 27 Aug 2025 18:25:29 +0100 Subject: [PATCH 06/17] style: add newline to fixture --- tests/fixtures/mcp-no-configkey.json5 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/fixtures/mcp-no-configkey.json5 b/tests/fixtures/mcp-no-configkey.json5 index f7f8826d..c80aa867 100644 --- a/tests/fixtures/mcp-no-configkey.json5 +++ b/tests/fixtures/mcp-no-configkey.json5 @@ -11,4 +11,4 @@ "type": "promptString" } ] -} \ No newline at end of file +} From e19a45296edf6ca4cd559452f4e7e6000aef159a Mon Sep 17 00:00:00 2001 From: Ashley Hindle Date: Wed, 27 Aug 2025 18:26:36 +0100 Subject: [PATCH 07/17] test: fix test failing on MacOS now we don't do home expansion --- tests/Unit/Install/HerdTest.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Unit/Install/HerdTest.php b/tests/Unit/Install/HerdTest.php index 1bbaeadf..822d6b53 100644 --- a/tests/Unit/Install/HerdTest.php +++ b/tests/Unit/Install/HerdTest.php @@ -77,7 +77,7 @@ function getHerdTestTempDir(): string expect($herd->mcpPath())->toBe($expected); })->onlyOnWindows(); -test('isMcpAvailable returns false when MCP file is missing', function () { +test('isMcpAvailable returns false when MCP file is missing from home', function () { $testHome = getHerdTestTempDir().'/home'; mkdir($testHome, 0755, true); $_SERVER['HOME'] = $testHome; @@ -85,9 +85,9 @@ function getHerdTestTempDir(): string $herd = new Herd; expect($herd->isMcpAvailable())->toBeFalse(); -}); +})->onlyOnWindows(); -test('isMcpAvailable returns true when MCP file exists', function () { +test('isMcpAvailable returns true when MCP file exists in home', function () { $testHome = getHerdTestTempDir().'/home'; mkdir($testHome, 0755, true); $_SERVER['HOME'] = $testHome; From 16874a9cf28e733362707cd14c8f31cc02cb7c27 Mon Sep 17 00:00:00 2001 From: Ashley Hindle Date: Wed, 27 Aug 2025 18:36:43 +0100 Subject: [PATCH 08/17] refactor: improve readability of comma detection --- src/Install/Mcp/FileWriter.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Install/Mcp/FileWriter.php b/src/Install/Mcp/FileWriter.php index b2db2eda..e513dba1 100644 --- a/src/Install/Mcp/FileWriter.php +++ b/src/Install/Mcp/FileWriter.php @@ -179,8 +179,7 @@ protected function needsCommaAfterBrace(string $content, int $bracePosition): bo $afterBrace = substr($content, $bracePosition + 1); $trimmed = preg_replace('/^\s*(?:\/\/.*$)?/m', '', $afterBrace); - // If next char is } (empty object) or nothing, no comma needed - return ! empty($trimmed) && $trimmed[0] !== '}'; + return filled($trimmed) && ! Str::startsWith($trimmed, '}'); } protected function findMatchingClosingBrace(string $content, int $openBracePos): int|false From e61a894279c66f872d783fff4956f1bc2a46bc15 Mon Sep 17 00:00:00 2001 From: ashleyhindle <454975+ashleyhindle@users.noreply.github.com> Date: Wed, 27 Aug 2025 19:29:47 +0000 Subject: [PATCH 09/17] Update CHANGELOG --- CHANGELOG.md | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f999f9a..a8effdb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,32 @@ # Release Notes -## [Unreleased](https://github.com/laravel/boost/compare/v1.0.18...main) +## [Unreleased](https://github.com/laravel/boost/compare/v1.0.19...main) + +## [v1.0.19](https://github.com/laravel/boost/compare/v1.0.18...v1.0.19) - 2025-08-27 + +### What's Changed + +* Refactor creating laravel application instance using Testbench by [@crynobone](https://github.com/crynobone) in https://github.com/laravel/boost/pull/127 +* Fix Tailwind CSS title on README.md for consistency by [@xavizera](https://github.com/xavizera) in https://github.com/laravel/boost/pull/159 +* feat: don't run Boost during testing by [@ashleyhindle](https://github.com/ashleyhindle) in https://github.com/laravel/boost/pull/144 +* Hide Internal Command `ExecuteToolCommand.php` from Artisan List by [@yitzwillroth](https://github.com/yitzwillroth) in https://github.com/laravel/boost/pull/155 +* chore: removes non necessary php version constrant by [@nunomaduro](https://github.com/nunomaduro) in https://github.com/laravel/boost/pull/166 +* chore: removes non necessary pint version constrant by [@nunomaduro](https://github.com/nunomaduro) in https://github.com/laravel/boost/pull/167 +* Do not autoload classes while boost:install by [@pushpak1300](https://github.com/pushpak1300) in https://github.com/laravel/boost/pull/180 +* fix: prevent unwanted "null" file creation on Windows during installation by [@andreilungeanu](https://github.com/andreilungeanu) in https://github.com/laravel/boost/pull/189 +* Improve `InjectBoost` middleware for response-type handling by [@pushpak1300](https://github.com/pushpak1300) in https://github.com/laravel/boost/pull/179 +* docs: README: Add Nova 4.x and 5.x by [@ashleyhindle](https://github.com/ashleyhindle) in https://github.com/laravel/boost/pull/213 +* refactor: change ./artisan to artisan by [@ashleyhindle](https://github.com/ashleyhindle) in https://github.com/laravel/boost/pull/214 +* feat: guidelines: add Inertia form guidelines by [@ashleyhindle](https://github.com/ashleyhindle) in https://github.com/laravel/boost/pull/211 + +### New Contributors + +* [@crynobone](https://github.com/crynobone) made their first contribution in https://github.com/laravel/boost/pull/127 +* [@xavizera](https://github.com/xavizera) made their first contribution in https://github.com/laravel/boost/pull/159 +* [@nunomaduro](https://github.com/nunomaduro) made their first contribution in https://github.com/laravel/boost/pull/166 +* [@andreilungeanu](https://github.com/andreilungeanu) made their first contribution in https://github.com/laravel/boost/pull/189 + +**Full Changelog**: https://github.com/laravel/boost/compare/v1.0.18...v1.0.19 ## [v1.0.18](https://github.com/laravel/boost/compare/v1.0.17...v1.0.18) - 2025-08-16 From 21e4280d69f5c914c08a662225a69cbfea646e76 Mon Sep 17 00:00:00 2001 From: Ashley Hindle Date: Wed, 27 Aug 2025 21:38:57 +0100 Subject: [PATCH 10/17] refactor: remove comments --- src/BoostServiceProvider.php | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/BoostServiceProvider.php b/src/BoostServiceProvider.php index 5e0541f1..a0d53219 100644 --- a/src/BoostServiceProvider.php +++ b/src/BoostServiceProvider.php @@ -180,10 +180,6 @@ private static function mapJsTypeToPsr3Level(string $type): string private function hookIntoResponses(Router $router): void { - // In Kernel-based apps (pre-Laravel 11 bootstrap), middleware groups can be - // defined or overwritten later in the boot cycle, which could remove any - // middleware we push here if we do it too early. Deferring this until the - // application is fully booted ensures our middleware stays appended. $this->app->booted(function () use ($router) { $router->pushMiddlewareToGroup('web', InjectBoost::class); }); From 8f164148f8d13f615ddc07b5024399ca6fd74004 Mon Sep 17 00:00:00 2001 From: Ashley Hindle Date: Thu, 28 Aug 2025 08:31:24 +0100 Subject: [PATCH 11/17] Feat: Detect env changes by default, fixes 130 From 70e5e124b607925ad1b1fd9354045e3dd67ab219 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Thu, 28 Aug 2025 13:25:17 +0530 Subject: [PATCH 12/17] add new line to fixtures --- tests/fixtures/mcp-comments-in-strings.json | 2 +- tests/fixtures/mcp-empty-configkey.json5 | 2 +- tests/fixtures/mcp-empty.json | 2 +- tests/fixtures/mcp-plain.json | 2 +- tests/fixtures/mcp-trailing-comma.json5 | 2 +- tests/fixtures/mcp-with-servers.json | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/fixtures/mcp-comments-in-strings.json b/tests/fixtures/mcp-comments-in-strings.json index b1245b68..34fa2955 100644 --- a/tests/fixtures/mcp-comments-in-strings.json +++ b/tests/fixtures/mcp-comments-in-strings.json @@ -7,4 +7,4 @@ "command": "test" } } -} \ No newline at end of file +} diff --git a/tests/fixtures/mcp-empty-configkey.json5 b/tests/fixtures/mcp-empty-configkey.json5 index 00bb00d7..d54fe282 100644 --- a/tests/fixtures/mcp-empty-configkey.json5 +++ b/tests/fixtures/mcp-empty-configkey.json5 @@ -8,4 +8,4 @@ "type": "promptString" } ] -} \ No newline at end of file +} diff --git a/tests/fixtures/mcp-empty.json b/tests/fixtures/mcp-empty.json index 9e26dfee..0967ef42 100644 --- a/tests/fixtures/mcp-empty.json +++ b/tests/fixtures/mcp-empty.json @@ -1 +1 @@ -{} \ No newline at end of file +{} diff --git a/tests/fixtures/mcp-plain.json b/tests/fixtures/mcp-plain.json index 98aeb42f..370c74d0 100644 --- a/tests/fixtures/mcp-plain.json +++ b/tests/fixtures/mcp-plain.json @@ -4,4 +4,4 @@ "nested": { "key": "value" } -} \ No newline at end of file +} diff --git a/tests/fixtures/mcp-trailing-comma.json5 b/tests/fixtures/mcp-trailing-comma.json5 index 4a65b57b..c5a4aef7 100644 --- a/tests/fixtures/mcp-trailing-comma.json5 +++ b/tests/fixtures/mcp-trailing-comma.json5 @@ -6,4 +6,4 @@ }, // Trailing comma here }, "inputs": [] -} \ No newline at end of file +} diff --git a/tests/fixtures/mcp-with-servers.json b/tests/fixtures/mcp-with-servers.json index 56414f3e..aa086418 100644 --- a/tests/fixtures/mcp-with-servers.json +++ b/tests/fixtures/mcp-with-servers.json @@ -6,4 +6,4 @@ } }, "other": "data" -} \ No newline at end of file +} From 75a4cc8765b924bb642cddc285418c17d132ed45 Mon Sep 17 00:00:00 2001 From: Ashley Hindle Date: Thu, 28 Aug 2025 09:43:52 +0100 Subject: [PATCH 13/17] feat: default process isolation to true --- src/Mcp/ToolExecutor.php | 26 ++------------------------ 1 file changed, 2 insertions(+), 24 deletions(-) diff --git a/src/Mcp/ToolExecutor.php b/src/Mcp/ToolExecutor.php index 7c8533e0..1e06ed85 100644 --- a/src/Mcp/ToolExecutor.php +++ b/src/Mcp/ToolExecutor.php @@ -13,14 +13,8 @@ class ToolExecutor { public function __construct() { - // } - /** - * Execute a tool with the given arguments. - * - * @param array $arguments - */ public function execute(string $toolClass, array $arguments = []): ToolResult { if (! ToolRegistry::isToolAllowed($toolClass)) { @@ -34,11 +28,6 @@ public function execute(string $toolClass, array $arguments = []): ToolResult return $this->executeInline($toolClass, $arguments); } - /** - * Execute tool in a separate process for isolation. - * - * @param array $arguments - */ protected function executeInProcess(string $toolClass, array $arguments): ToolResult { $command = [ @@ -77,14 +66,10 @@ protected function executeInProcess(string $toolClass, array $arguments): ToolRe } } - /** - * Execute tool inline (current process). - * - * @param array $arguments - */ protected function executeInline(string $toolClass, array $arguments): ToolResult { try { + /** @var \Laravel\Mcp\Server\Tool $tool */ $tool = app($toolClass); return $tool->handle($arguments); @@ -93,22 +78,15 @@ protected function executeInline(string $toolClass, array $arguments): ToolResul } } - /** - * Check if process isolation should be used. - */ protected function shouldUseProcessIsolation(): bool { - // Never use process isolation in testing environment if (app()->environment('testing')) { return false; } - return config('boost.process_isolation.enabled', false); + return config('boost.process_isolation.enabled', true); } - /** - * Get the execution timeout. - */ protected function getTimeout(): int { return config('boost.process_isolation.timeout', 180); From 663b96262c00e3721be7bee0ebd3bf0e64b11ebc Mon Sep 17 00:00:00 2001 From: Ashley Hindle Date: Thu, 28 Aug 2025 10:15:34 +0100 Subject: [PATCH 14/17] feat: add tests for process isolation --- src/Mcp/ToolExecutor.php | 26 +++-- tests/Feature/Mcp/ToolExecutorTest.php | 134 ++++++++++++++++++++++++- 2 files changed, 150 insertions(+), 10 deletions(-) diff --git a/src/Mcp/ToolExecutor.php b/src/Mcp/ToolExecutor.php index 1e06ed85..9ac3c044 100644 --- a/src/Mcp/ToolExecutor.php +++ b/src/Mcp/ToolExecutor.php @@ -30,13 +30,7 @@ public function execute(string $toolClass, array $arguments = []): ToolResult protected function executeInProcess(string $toolClass, array $arguments): ToolResult { - $command = [ - PHP_BINARY, - base_path('artisan'), - 'boost:execute-tool', - $toolClass, - base64_encode(json_encode($arguments)), - ]; + $command = $this->buildCommand($toolClass, $arguments); $process = new Process($command); $process->setTimeout($this->getTimeout()); @@ -135,4 +129,22 @@ protected function reconstructToolResult(array $data): ToolResult return ToolResult::text(''); } + + /** + * Build the command array for executing a tool in a subprocess. + * + * @param string $toolClass + * @param array $arguments + * @return array + */ + protected function buildCommand(string $toolClass, array $arguments): array + { + return [ + PHP_BINARY, + base_path('artisan'), + 'boost:execute-tool', + $toolClass, + base64_encode(json_encode($arguments)), + ]; + } } diff --git a/tests/Feature/Mcp/ToolExecutorTest.php b/tests/Feature/Mcp/ToolExecutorTest.php index 4bb998d7..b226b520 100644 --- a/tests/Feature/Mcp/ToolExecutorTest.php +++ b/tests/Feature/Mcp/ToolExecutorTest.php @@ -2,6 +2,8 @@ use Laravel\Boost\Mcp\ToolExecutor; use Laravel\Boost\Mcp\Tools\ApplicationInfo; +use Laravel\Boost\Mcp\Tools\GetConfig; +use Laravel\Boost\Mcp\Tools\Tinker; use Laravel\Mcp\Server\Tools\ToolResult; test('can execute tool inline', function () { @@ -14,10 +16,136 @@ expect($result)->toBeInstanceOf(ToolResult::class); }); +test('can execute tool with process isolation', function () { + // Enable process isolation for this test + config(['boost.process_isolation.enabled' => true]); + + // Create a mock that overrides buildCommand to work with testbench + $executor = Mockery::mock(ToolExecutor::class)->makePartial() + ->shouldAllowMockingProtectedMethods(); + $executor->shouldReceive('buildCommand') + ->once() + ->andReturnUsing(fn ($toolClass, $arguments) => buildSubprocessCommand($toolClass, $arguments)); + + $result = $executor->execute(GetConfig::class, ['key' => 'app.name']); + + expect($result)->toBeInstanceOf(ToolResult::class); + + // If there's an error, extract the text content properly + if ($result->isError) { + $errorText = $result->content[0]->text ?? 'Unknown error'; + expect(false)->toBeTrue("Tool execution failed with error: {$errorText}"); + } + + expect($result->isError)->toBeFalse(); + expect($result->content)->toBeArray(); + + // The content should contain the app name (which should be "Laravel" in testbench) + $textContent = $result->content[0]->text ?? ''; + expect($textContent)->toContain('Laravel'); +}); + test('rejects unregistered tools', function () { $executor = app(ToolExecutor::class); - $result = $executor->execute('NonExistentToolClass', []); + $result = $executor->execute('NonExistentToolClass'); - expect($result)->toBeInstanceOf(ToolResult::class); - expect($result->isError)->toBeTrue(); + expect($result)->toBeInstanceOf(ToolResult::class) + ->and($result->isError)->toBeTrue(); }); + +test('subprocess proves fresh process isolation', function () { + config(['boost.process_isolation.enabled' => true]); + + $executor = Mockery::mock(ToolExecutor::class)->makePartial() + ->shouldAllowMockingProtectedMethods(); + $executor->shouldReceive('buildCommand') + ->andReturnUsing(fn ($toolClass, $arguments) => buildSubprocessCommand($toolClass, $arguments)); + + $result1 = $executor->execute(Tinker::class, ['code' => 'return getmypid();']); + $result2 = $executor->execute(Tinker::class, ['code' => 'return getmypid();']); + + expect($result1->isError)->toBeFalse(); + expect($result2->isError)->toBeFalse(); + + $pid1 = json_decode($result1->content[0]->text, true)['result']; + $pid2 = json_decode($result2->content[0]->text, true)['result']; + + expect($pid1)->toBeInt()->not->toBe(getmypid()); + expect($pid2)->toBeInt()->not->toBe(getmypid()); + expect($pid1)->not()->toBe($pid2); +}); + +test('subprocess sees modified autoloaded code changes', function () { + config(['boost.process_isolation.enabled' => true]); + + $executor = Mockery::mock(ToolExecutor::class)->makePartial() + ->shouldAllowMockingProtectedMethods(); + $executor->shouldReceive('buildCommand') + ->andReturnUsing(fn ($toolClass, $arguments) => buildSubprocessCommand($toolClass, $arguments)); + + // Path to the GetConfig tool that we'll temporarily modify + // TODO: Improve for parallelisation + $toolPath = dirname(__DIR__, 3).'/src/Mcp/Tools/GetConfig.php'; + $originalContent = file_get_contents($toolPath); + + $cleanup = function () use ($toolPath, $originalContent) { + file_put_contents($toolPath, $originalContent); + }; + + try { + $result1 = $executor->execute(GetConfig::class, ['key' => 'app.name']); + + expect($result1->isError)->toBeFalse(); + $response1 = json_decode($result1->content[0]->text, true); + expect($response1['value'])->toBe('Laravel'); // Normal testbench app name + + // Modify GetConfig.php to return a different hardcoded value + $modifiedContent = str_replace( + "'value' => Config::get(\$key),", + "'value' => 'MODIFIED_BY_TEST',", + $originalContent + ); + file_put_contents($toolPath, $modifiedContent); + + $result2 = $executor->execute(GetConfig::class, ['key' => 'app.name']); + $response2 = json_decode($result2->content[0]->text, true); + + expect($result2->isError)->toBeFalse(); + expect($response2['value'])->toBe('MODIFIED_BY_TEST'); // Using updated code, not cached + } finally { + $cleanup(); + } +}); + +/** + * Build a subprocess command that bootstraps testbench and executes an MCP tool via artisan. + */ +function buildSubprocessCommand(string $toolClass, array $arguments): array +{ + $argumentsEncoded = base64_encode(json_encode($arguments)); + $testScript = sprintf( + 'require_once "%s/vendor/autoload.php"; '. + 'use Orchestra\Testbench\Foundation\Application as Testbench; '. + 'use Orchestra\Testbench\Foundation\Config as TestbenchConfig; '. + 'use Illuminate\Support\Facades\Artisan; '. + 'use Symfony\Component\Console\Output\BufferedOutput; '. + // Bootstrap testbench like all.php does + '$app = Testbench::createFromConfig(new TestbenchConfig([]), options: ["enables_package_discoveries" => false]); '. + 'Illuminate\Container\Container::setInstance($app); '. + '$kernel = $app->make("Illuminate\Contracts\Console\Kernel"); '. + '$kernel->bootstrap(); '. + // Register the ExecuteToolCommand + '$kernel->registerCommand(new \Laravel\Boost\Console\ExecuteToolCommand()); '. + '$output = new BufferedOutput(); '. + '$result = Artisan::call("boost:execute-tool", ['. + ' "tool" => "%s", '. + ' "arguments" => "%s" '. + '], $output); '. + 'echo $output->fetch();', + dirname(__DIR__, 3), // Go up from tests/Feature/Mcp to project root + addslashes($toolClass), + $argumentsEncoded + ); + + return [PHP_BINARY, '-r', $testScript]; +} From dc2146ee81d00f9670479cddab0416721af8c9c8 Mon Sep 17 00:00:00 2001 From: Ashley Hindle Date: Thu, 28 Aug 2025 10:44:47 +0100 Subject: [PATCH 15/17] feat: add duplicate protection for JSON 5 files --- src/Install/Mcp/FileWriter.php | 31 ++- tests/Unit/Install/Mcp/FileWriterTest.php | 244 +++++++++++++--------- 2 files changed, 179 insertions(+), 96 deletions(-) diff --git a/src/Install/Mcp/FileWriter.php b/src/Install/Mcp/FileWriter.php index e513dba1..68282413 100644 --- a/src/Install/Mcp/FileWriter.php +++ b/src/Install/Mcp/FileWriter.php @@ -101,11 +101,18 @@ protected function injectIntoExistingConfigKey(string $content, array $matches): return false; } + // Filter out servers that already exist + $serversToAdd = $this->filterExistingServers($content, $openBracePos, $closeBracePos); + + if (empty($serversToAdd)) { + return true; + } + // Detect indentation from surrounding content $indentLength = $this->detectIndentation($content, $closeBracePos); $serverJsonParts = []; - foreach ($this->serversToAdd as $key => $serverConfig) { + foreach ($serversToAdd as $key => $serverConfig) { $serverJsonParts[] = $this->generateServerJson($key, $serverConfig, $indentLength); } $serversJson = implode(','."\n", $serverJsonParts); @@ -130,6 +137,28 @@ protected function injectIntoExistingConfigKey(string $content, array $matches): return $this->writeFile($newContent); } + protected function filterExistingServers(string $content, int $openBracePos, int $closeBracePos): array + { + $configContent = substr($content, $openBracePos + 1, $closeBracePos - $openBracePos - 1); + $filteredServers = []; + + foreach ($this->serversToAdd as $key => $serverConfig) { + if (! $this->serverExistsInContent($configContent, $key)) { + $filteredServers[$key] = $serverConfig; + } + } + + return $filteredServers; + } + + protected function serverExistsInContent(string $content, string $serverKey): bool + { + $quotedPattern = '/["\']'.preg_quote($serverKey, '/').'["\']\\s*:/'; + $unquotedPattern = '/(?<=^|\\s|,|{)'.preg_quote($serverKey, '/').'\\s*:/m'; + + return preg_match($quotedPattern, $content) || preg_match($unquotedPattern, $content); + } + protected function injectNewConfigKey(string $content): bool { $openBracePos = strpos($content, '{'); diff --git a/tests/Unit/Install/Mcp/FileWriterTest.php b/tests/Unit/Install/Mcp/FileWriterTest.php index 7f33bf42..5d1dc442 100644 --- a/tests/Unit/Install/Mcp/FileWriterTest.php +++ b/tests/Unit/Install/Mcp/FileWriterTest.php @@ -282,6 +282,60 @@ expect($writtenContent)->toContain('// Ooo, pretty cool'); // Inline comments preserved }); +test('injecting twice into existing JSON 5 doesn\'t cause duplicates', function () { + $capturedContent = ''; + + File::clearResolvedInstances(); + File::partialMock(); + + File::shouldReceive('ensureDirectoryExists')->once(); + File::shouldReceive('exists')->andReturn(true); + File::shouldReceive('size')->andReturn(1000); + File::shouldReceive('get')->andReturn(fixture('mcp.json5')); + File::shouldReceive('put') + ->with( + Mockery::capture($capturedPath), + Mockery::capture($capturedContent) + ) + ->andReturn(true); + + $result = (new FileWriter('/path/to/mcp.json')) + ->configKey('servers') // mcp.json5 uses "servers" not "mcpServers" + ->addServer('boost', 'php', ['artisan', 'boost:mcp']) + ->save(); + + $boostCounts = substr_count($capturedContent, '"boost"'); + expect($result)->toBeTrue(); + expect($boostCounts)->toBe(1); + expect($capturedContent)->toContain('"boost"'); // New server added + expect($capturedContent)->toContain('mysql'); // Existing server preserved + expect($capturedContent)->toContain('laravel-boost'); // Existing server preserved + expect($capturedContent)->toContain('// Here are comments within my JSON'); // Comments preserved + expect($capturedContent)->toContain('// Ooo, pretty cool'); // Inline comments preserved + + $newContent = $capturedContent; + + File::clearResolvedInstances(); + File::partialMock(); + + File::shouldReceive('ensureDirectoryExists')->once(); + File::shouldReceive('exists')->andReturn(true); + File::shouldReceive('size')->andReturn(1000); + File::shouldReceive('get')->andReturn($newContent); + + $result = (new FileWriter('/path/to/mcp.json')) + ->configKey('servers') + ->addServer('boost', 'php', ['artisan', 'boost:mcp']) + ->save(); + + // Second call should return true but not modify the file since boost already exists + expect($result)->toBeTrue(); + + // We should still have only one instance of the boost MCP server + $boostCounts = substr_count($capturedContent, '"boost"'); + expect($boostCounts)->toBe(1); +}); + test('injects into empty configKey object', function () { $writtenContent = ''; @@ -364,35 +418,35 @@ function newFileServerConfigurations(): array { return [ 'single server without args or env' => [ - 'configKey' => 'servers', - 'servers' => [ + 'servers', + [ 'im-new-here' => ['command' => './start-mcp'], ], - 'expectedJson' => '{"servers":{"im-new-here":{"command":"./start-mcp"}}}', + '{"servers":{"im-new-here":{"command":"./start-mcp"}}}', ], 'single server with args' => [ - 'configKey' => 'mcpServers', - 'servers' => [ + 'mcpServers', + [ 'boost' => [ 'command' => 'php', 'args' => ['artisan', 'boost:mcp'], ], ], - 'expectedJson' => '{"mcpServers":{"boost":{"command":"php","args":["artisan","boost:mcp"]}}}', + '{"mcpServers":{"boost":{"command":"php","args":["artisan","boost:mcp"]}}}', ], 'single server with env' => [ - 'configKey' => 'servers', - 'servers' => [ + 'servers', + [ 'mysql' => [ 'command' => 'npx', 'env' => ['DB_HOST' => 'localhost', 'DB_PORT' => '3306'], ], ], - 'expectedJson' => '{"servers":{"mysql":{"command":"npx","env":{"DB_HOST":"localhost","DB_PORT":"3306"}}}}', + '{"servers":{"mysql":{"command":"npx","env":{"DB_HOST":"localhost","DB_PORT":"3306"}}}}', ], 'multiple servers mixed' => [ - 'configKey' => 'mcpServers', - 'servers' => [ + 'mcpServers', + [ 'boost' => [ 'command' => 'php', 'args' => ['artisan', 'boost:mcp'], @@ -403,14 +457,14 @@ function newFileServerConfigurations(): array 'env' => ['DB_HOST' => 'localhost'], ], ], - 'expectedJson' => '{"mcpServers":{"boost":{"command":"php","args":["artisan","boost:mcp"]},"mysql":{"command":"npx","args":["@benborla29/mcp-server-mysql"],"env":{"DB_HOST":"localhost"}}}}', + '{"mcpServers":{"boost":{"command":"php","args":["artisan","boost:mcp"]},"mysql":{"command":"npx","args":["@benborla29/mcp-server-mysql"],"env":{"DB_HOST":"localhost"}}}}', ], 'custom config key' => [ - 'configKey' => 'customKey', - 'servers' => [ + 'customKey', + [ 'test' => ['command' => 'test-cmd'], ], - 'expectedJson' => '{"customKey":{"test":{"command":"test-cmd"}}}', + '{"customKey":{"test":{"command":"test-cmd"}}}', ], ]; } @@ -419,49 +473,49 @@ function commentDetectionCases(): array { return [ 'plain JSON no comments' => [ - 'content' => '{"servers": {"test": {"command": "npm"}}}', - 'expected' => false, - 'description' => 'Plain JSON should return false', + '{"servers": {"test": {"command": "npm"}}}', + false, + 'Plain JSON should return false', ], 'JSON with comments in strings' => [ - 'content' => '{"exampleCode": "// here is the example code\n false, - 'description' => 'Comments inside strings should not be detected as real comments', + '{"exampleCode": "// here is the example code\n [ - 'content' => '{"servers": {"test": "value"} // this is a real comment}', - 'expected' => true, - 'description' => 'Real JSON5 line comments should be detected', + '{"servers": {"test": "value"} // this is a real comment}', + true, + 'Real JSON5 line comments should be detected', ], 'JSON5 with comment at start of line' => [ - 'content' => '{\n // This is a comment\n "servers": {}\n}', - 'expected' => true, - 'description' => 'Line comments at start should be detected', + '{\n // This is a comment\n "servers": {}\n}', + true, + 'Line comments at start should be detected', ], 'complex string with escaped quotes' => [ - 'content' => '{"code": "console.log(\\"// not a comment\\");", "other": "value"}', - 'expected' => false, - 'description' => 'Comments in strings with escaped quotes should not be detected', + '{"code": "console.log(\\"// not a comment\\");", "other": "value"}', + false, + 'Comments in strings with escaped quotes should not be detected', ], 'multiple comments in strings' => [ - 'content' => '{"example1": "// comment 1", "example2": "some // comment 2 here"}', - 'expected' => false, - 'description' => 'Multiple comments in different strings should not be detected', + '{"example1": "// comment 1", "example2": "some // comment 2 here"}', + false, + 'Multiple comments in different strings should not be detected', ], 'mixed real and string comments' => [ - 'content' => '{"example": "// fake comment"} // real comment', - 'expected' => true, - 'description' => 'Should detect real comment even when fake ones exist in strings', + '{"example": "// fake comment"} // real comment', + true, + 'Should detect real comment even when fake ones exist in strings', ], 'empty string' => [ - 'content' => '', - 'expected' => false, - 'description' => 'Empty string should return false', + '', + false, + 'Empty string should return false', ], 'single slash not comment' => [ - 'content' => '{"path": "/usr/bin/test"}', - 'expected' => false, - 'description' => 'Single slash should not be detected as comment', + '{"path": "/usr/bin/test"}', + false, + 'Single slash should not be detected as comment', ], ]; } @@ -470,39 +524,39 @@ function trailingCommaCases(): array { return [ 'valid JSON no trailing comma' => [ - 'content' => '{"servers": {"test": "value"}}', - 'expected' => true, - 'description' => 'Valid JSON should return true (is plain JSON)', + '{"servers": {"test": "value"}}', + true, + 'Valid JSON should return true (is plain JSON)', ], 'trailing comma in object same line' => [ - 'content' => '{"servers": {"test": "value",}}', - 'expected' => false, - 'description' => 'Trailing comma in object should return false (is JSON5)', + '{"servers": {"test": "value",}}', + false, + 'Trailing comma in object should return false (is JSON5)', ], 'trailing comma in array same line' => [ - 'content' => '{"items": ["a", "b", "c",]}', - 'expected' => false, - 'description' => 'Trailing comma in array should return false (is JSON5)', + '{"items": ["a", "b", "c",]}', + false, + 'Trailing comma in array should return false (is JSON5)', ], 'trailing comma across newlines in object' => [ - 'content' => "{\n \"servers\": {\n \"test\": \"value\",\n }\n}", - 'expected' => false, - 'description' => 'Trailing comma across newlines in object should be detected', + "{\n \"servers\": {\n \"test\": \"value\",\n }\n}", + false, + 'Trailing comma across newlines in object should be detected', ], 'trailing comma across newlines in array' => [ - 'content' => "{\n \"items\": [\n \"a\",\n \"b\",\n ]\n}", - 'expected' => false, - 'description' => 'Trailing comma across newlines in array should be detected', + "{\n \"items\": [\n \"a\",\n \"b\",\n ]\n}", + false, + 'Trailing comma across newlines in array should be detected', ], 'trailing comma with tabs and spaces' => [ - 'content' => "{\n \"test\": \"value\",\t \n}", - 'expected' => false, - 'description' => 'Trailing comma with mixed whitespace should be detected', + "{\n \"test\": \"value\",\t \n}", + false, + 'Trailing comma with mixed whitespace should be detected', ], 'comma in string not trailing' => [ - 'content' => '{"example": "value,", "other": "test"}', - 'expected' => true, - 'description' => 'Comma inside string should not be detected as trailing', + '{"example": "value,", "other": "test"}', + true, + 'Comma inside string should not be detected as trailing', ], ]; } @@ -511,52 +565,52 @@ function indentationDetectionCases(): array { return [ 'mcp.json5 servers indentation' => [ - 'content' => "{\n // Here are comments within my JSON\n \"servers\": {\n \"mysql\": {\n \"command\": \"npx\"\n },\n \"laravel-boost\": {\n \"command\": \"php\"\n }\n },\n \"inputs\": []\n}", - 'position' => 200, // Position near end of servers block - 'expected' => 8, - 'description' => 'Should detect 8 spaces for server definitions in mcp.json5', + "{\n // Here are comments within my JSON\n \"servers\": {\n \"mysql\": {\n \"command\": \"npx\"\n },\n \"laravel-boost\": {\n \"command\": \"php\"\n }\n },\n \"inputs\": []\n}", + 200, // Position near end of servers block + 8, + 'Should detect 8 spaces for server definitions in mcp.json5', ], 'nested object with 4-space base indent' => [ - 'content' => "{\n \"config\": {\n \"server1\": {\n \"command\": \"test\"\n }\n }\n}", - 'position' => 80, - 'expected' => 8, - 'description' => 'Should detect 8 spaces for nested server definitions', + "{\n \"config\": {\n \"server1\": {\n \"command\": \"test\"\n }\n }\n}", + 80, + 8, + 'Should detect 8 spaces for nested server definitions', ], 'no previous server definitions' => [ - 'content' => "{\n \"inputs\": []\n}", - 'position' => 20, - 'expected' => 8, - 'description' => 'Should fallback to 8 spaces when no server definitions found', + "{\n \"inputs\": []\n}", + 20, + 8, + 'Should fallback to 8 spaces when no server definitions found', ], 'deeper nesting with 2-space indent' => [ - 'content' => "{\n \"config\": {\n \"servers\": {\n \"mysql\": {\n \"command\": \"test\"\n }\n }\n }\n}", - 'position' => 80, - 'expected' => 6, - 'description' => 'Should detect correct indentation in deeply nested structures', + "{\n \"config\": {\n \"servers\": {\n \"mysql\": {\n \"command\": \"test\"\n }\n }\n }\n}", + 80, + 6, + 'Should detect correct indentation in deeply nested structures', ], 'single server definition at root level' => [ - 'content' => "{\n\"mysql\": {\n \"command\": \"npx\"\n}\n}", - 'position' => 30, - 'expected' => 0, - 'description' => 'Should detect no indentation for root-level server definitions', + "{\n\"mysql\": {\n \"command\": \"npx\"\n}\n}", + 30, + 0, + 'Should detect no indentation for root-level server definitions', ], 'multiple server definitions with consistent indentation' => [ - 'content' => "{\n \"servers\": {\n \"mysql\": {\n \"command\": \"npx\"\n },\n \"postgres\": {\n \"command\": \"pg\"\n }\n }\n}", - 'position' => 150, - 'expected' => 8, - 'description' => 'Should consistently detect indentation across multiple servers', + "{\n \"servers\": {\n \"mysql\": {\n \"command\": \"npx\"\n },\n \"postgres\": {\n \"command\": \"pg\"\n }\n }\n}", + 150, + 8, + 'Should consistently detect indentation across multiple servers', ], 'server definition with comments' => [ - 'content' => "{\n // Comment here\n \"servers\": {\n \"mysql\": { // inline comment\n \"command\": \"npx\"\n }\n }\n}", - 'position' => 120, - 'expected' => 8, - 'description' => 'Should detect indentation correctly when comments are present', + "{\n // Comment here\n \"servers\": {\n \"mysql\": { // inline comment\n \"command\": \"npx\"\n }\n }\n}", + 120, + 8, + 'Should detect indentation correctly when comments are present', ], 'empty content' => [ - 'content' => '', - 'position' => 0, - 'expected' => 8, - 'description' => 'Should fallback to 8 spaces for empty content', + '', + 0, + 8, + 'Should fallback to 8 spaces for empty content', ], ]; } From 1f899a8aca07b0377cfbe9de4435b29820fabbf7 Mon Sep 17 00:00:00 2001 From: Ashley Hindle Date: Thu, 28 Aug 2025 13:26:10 +0100 Subject: [PATCH 16/17] refactor: filewriter: less magic numbers --- src/Install/Mcp/FileWriter.php | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/Install/Mcp/FileWriter.php b/src/Install/Mcp/FileWriter.php index 68282413..dce5e66d 100644 --- a/src/Install/Mcp/FileWriter.php +++ b/src/Install/Mcp/FileWriter.php @@ -15,6 +15,8 @@ class FileWriter protected array $serversToAdd = []; + protected int $defaultIndentation = 8; + public function __construct(string $filePath) { $this->filePath = $filePath; @@ -28,8 +30,8 @@ public function configKey(string $key): self } /** - * Add a new MCP server to the configuration while preserving JSON5 formatting. - * + * @param string $key MCP Server Name + * @param string $command * @param array $args * @param array $env */ @@ -313,7 +315,7 @@ public function detectIndentation(string $content, int $nearPosition): int } // Fallback: assume 8 spaces (2 levels of 4-space indentation typical for JSON) - return 8; + return $this->defaultIndentation; } /** From b93d49d0e7caee680289db5ea6cd7e4cce70439b Mon Sep 17 00:00:00 2001 From: Ashley Hindle Date: Thu, 28 Aug 2025 14:50:27 +0100 Subject: [PATCH 17/17] fix: MCP tool having outdated ENV vars Don't pass env vars to tool executor from long running parent process, force child to get own env --- src/Mcp/ToolExecutor.php | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/Mcp/ToolExecutor.php b/src/Mcp/ToolExecutor.php index 9ac3c044..43cf68f5 100644 --- a/src/Mcp/ToolExecutor.php +++ b/src/Mcp/ToolExecutor.php @@ -4,6 +4,8 @@ namespace Laravel\Boost\Mcp; +use Dotenv\Dotenv; +use Illuminate\Support\Env; use Laravel\Mcp\Server\Tools\ToolResult; use Symfony\Component\Process\Exception\ProcessFailedException; use Symfony\Component\Process\Exception\ProcessTimedOutException; @@ -32,8 +34,19 @@ protected function executeInProcess(string $toolClass, array $arguments): ToolRe { $command = $this->buildCommand($toolClass, $arguments); - $process = new Process($command); - $process->setTimeout($this->getTimeout()); + // We need to 'unset' env vars that will be passed from the parent process to the child process, stopping the child process from reading .env and getting updated values + $env = (Dotenv::create( + Env::getRepository(), + app()->environmentPath(), + app()->environmentFile() + ))->safeLoad(); + $cleanEnv = array_fill_keys(array_keys($env), false); + + $process = new Process( + command: $command, + env: $cleanEnv, + timeout: $this->getTimeout() + ); try { $process->mustRun(); @@ -45,9 +58,7 @@ protected function executeInProcess(string $toolClass, array $arguments): ToolRe return ToolResult::error('Invalid JSON output from tool process: '.json_last_error_msg()); } - // Reconstruct ToolResult from the JSON output return $this->reconstructToolResult($decoded); - } catch (ProcessTimedOutException $e) { $process->stop();