|
2 | 2 |
|
3 | 3 | use Laravel\Boost\Mcp\ToolExecutor; |
4 | 4 | use Laravel\Boost\Mcp\Tools\ApplicationInfo; |
| 5 | +use Laravel\Boost\Mcp\Tools\GetConfig; |
| 6 | +use Laravel\Boost\Mcp\Tools\Tinker; |
5 | 7 | use Laravel\Mcp\Server\Tools\ToolResult; |
6 | 8 |
|
7 | 9 | test('can execute tool inline', function () { |
|
14 | 16 | expect($result)->toBeInstanceOf(ToolResult::class); |
15 | 17 | }); |
16 | 18 |
|
| 19 | +test('can execute tool with process isolation', function () { |
| 20 | + // Enable process isolation for this test |
| 21 | + config(['boost.process_isolation.enabled' => true]); |
| 22 | + |
| 23 | + // Create a mock that overrides buildCommand to work with testbench |
| 24 | + $executor = Mockery::mock(ToolExecutor::class)->makePartial() |
| 25 | + ->shouldAllowMockingProtectedMethods(); |
| 26 | + $executor->shouldReceive('buildCommand') |
| 27 | + ->once() |
| 28 | + ->andReturnUsing(fn ($toolClass, $arguments) => buildSubprocessCommand($toolClass, $arguments)); |
| 29 | + |
| 30 | + $result = $executor->execute(GetConfig::class, ['key' => 'app.name']); |
| 31 | + |
| 32 | + expect($result)->toBeInstanceOf(ToolResult::class); |
| 33 | + |
| 34 | + // If there's an error, extract the text content properly |
| 35 | + if ($result->isError) { |
| 36 | + $errorText = $result->content[0]->text ?? 'Unknown error'; |
| 37 | + expect(false)->toBeTrue("Tool execution failed with error: {$errorText}"); |
| 38 | + } |
| 39 | + |
| 40 | + expect($result->isError)->toBeFalse(); |
| 41 | + expect($result->content)->toBeArray(); |
| 42 | + |
| 43 | + // The content should contain the app name (which should be "Laravel" in testbench) |
| 44 | + $textContent = $result->content[0]->text ?? ''; |
| 45 | + expect($textContent)->toContain('Laravel'); |
| 46 | +}); |
| 47 | + |
17 | 48 | test('rejects unregistered tools', function () { |
18 | 49 | $executor = app(ToolExecutor::class); |
19 | | - $result = $executor->execute('NonExistentToolClass', []); |
| 50 | + $result = $executor->execute('NonExistentToolClass'); |
20 | 51 |
|
21 | | - expect($result)->toBeInstanceOf(ToolResult::class); |
22 | | - expect($result->isError)->toBeTrue(); |
| 52 | + expect($result)->toBeInstanceOf(ToolResult::class) |
| 53 | + ->and($result->isError)->toBeTrue(); |
23 | 54 | }); |
| 55 | + |
| 56 | +test('subprocess proves fresh process isolation', function () { |
| 57 | + config(['boost.process_isolation.enabled' => true]); |
| 58 | + |
| 59 | + $executor = Mockery::mock(ToolExecutor::class)->makePartial() |
| 60 | + ->shouldAllowMockingProtectedMethods(); |
| 61 | + $executor->shouldReceive('buildCommand') |
| 62 | + ->andReturnUsing(fn ($toolClass, $arguments) => buildSubprocessCommand($toolClass, $arguments)); |
| 63 | + |
| 64 | + $result1 = $executor->execute(Tinker::class, ['code' => 'return getmypid();']); |
| 65 | + $result2 = $executor->execute(Tinker::class, ['code' => 'return getmypid();']); |
| 66 | + |
| 67 | + expect($result1->isError)->toBeFalse(); |
| 68 | + expect($result2->isError)->toBeFalse(); |
| 69 | + |
| 70 | + $pid1 = json_decode($result1->content[0]->text, true)['result']; |
| 71 | + $pid2 = json_decode($result2->content[0]->text, true)['result']; |
| 72 | + |
| 73 | + expect($pid1)->toBeInt()->not->toBe(getmypid()); |
| 74 | + expect($pid2)->toBeInt()->not->toBe(getmypid()); |
| 75 | + expect($pid1)->not()->toBe($pid2); |
| 76 | +}); |
| 77 | + |
| 78 | +test('subprocess sees modified autoloaded code changes', function () { |
| 79 | + config(['boost.process_isolation.enabled' => true]); |
| 80 | + |
| 81 | + $executor = Mockery::mock(ToolExecutor::class)->makePartial() |
| 82 | + ->shouldAllowMockingProtectedMethods(); |
| 83 | + $executor->shouldReceive('buildCommand') |
| 84 | + ->andReturnUsing(fn ($toolClass, $arguments) => buildSubprocessCommand($toolClass, $arguments)); |
| 85 | + |
| 86 | + // Path to the GetConfig tool that we'll temporarily modify |
| 87 | + // TODO: Improve for parallelisation |
| 88 | + $toolPath = dirname(__DIR__, 3).'/src/Mcp/Tools/GetConfig.php'; |
| 89 | + $originalContent = file_get_contents($toolPath); |
| 90 | + |
| 91 | + $cleanup = function () use ($toolPath, $originalContent) { |
| 92 | + file_put_contents($toolPath, $originalContent); |
| 93 | + }; |
| 94 | + |
| 95 | + try { |
| 96 | + $result1 = $executor->execute(GetConfig::class, ['key' => 'app.name']); |
| 97 | + |
| 98 | + expect($result1->isError)->toBeFalse(); |
| 99 | + $response1 = json_decode($result1->content[0]->text, true); |
| 100 | + expect($response1['value'])->toBe('Laravel'); // Normal testbench app name |
| 101 | + |
| 102 | + // Modify GetConfig.php to return a different hardcoded value |
| 103 | + $modifiedContent = str_replace( |
| 104 | + "'value' => Config::get(\$key),", |
| 105 | + "'value' => 'MODIFIED_BY_TEST',", |
| 106 | + $originalContent |
| 107 | + ); |
| 108 | + file_put_contents($toolPath, $modifiedContent); |
| 109 | + |
| 110 | + $result2 = $executor->execute(GetConfig::class, ['key' => 'app.name']); |
| 111 | + $response2 = json_decode($result2->content[0]->text, true); |
| 112 | + |
| 113 | + expect($result2->isError)->toBeFalse(); |
| 114 | + expect($response2['value'])->toBe('MODIFIED_BY_TEST'); // Using updated code, not cached |
| 115 | + } finally { |
| 116 | + $cleanup(); |
| 117 | + } |
| 118 | +}); |
| 119 | + |
| 120 | +/** |
| 121 | + * Build a subprocess command that bootstraps testbench and executes an MCP tool via artisan. |
| 122 | + */ |
| 123 | +function buildSubprocessCommand(string $toolClass, array $arguments): array |
| 124 | +{ |
| 125 | + $argumentsEncoded = base64_encode(json_encode($arguments)); |
| 126 | + $testScript = sprintf( |
| 127 | + 'require_once "%s/vendor/autoload.php"; '. |
| 128 | + 'use Orchestra\Testbench\Foundation\Application as Testbench; '. |
| 129 | + 'use Orchestra\Testbench\Foundation\Config as TestbenchConfig; '. |
| 130 | + 'use Illuminate\Support\Facades\Artisan; '. |
| 131 | + 'use Symfony\Component\Console\Output\BufferedOutput; '. |
| 132 | + // Bootstrap testbench like all.php does |
| 133 | + '$app = Testbench::createFromConfig(new TestbenchConfig([]), options: ["enables_package_discoveries" => false]); '. |
| 134 | + 'Illuminate\Container\Container::setInstance($app); '. |
| 135 | + '$kernel = $app->make("Illuminate\Contracts\Console\Kernel"); '. |
| 136 | + '$kernel->bootstrap(); '. |
| 137 | + // Register the ExecuteToolCommand |
| 138 | + '$kernel->registerCommand(new \Laravel\Boost\Console\ExecuteToolCommand()); '. |
| 139 | + '$output = new BufferedOutput(); '. |
| 140 | + '$result = Artisan::call("boost:execute-tool", ['. |
| 141 | + ' "tool" => "%s", '. |
| 142 | + ' "arguments" => "%s" '. |
| 143 | + '], $output); '. |
| 144 | + 'echo $output->fetch();', |
| 145 | + dirname(__DIR__, 3), // Go up from tests/Feature/Mcp to project root |
| 146 | + addslashes($toolClass), |
| 147 | + $argumentsEncoded |
| 148 | + ); |
| 149 | + |
| 150 | + return [PHP_BINARY, '-r', $testScript]; |
| 151 | +} |
0 commit comments