Skip to content

Commit 663b962

Browse files
committed
feat: add tests for process isolation
1 parent 75a4cc8 commit 663b962

File tree

2 files changed

+150
-10
lines changed

2 files changed

+150
-10
lines changed

src/Mcp/ToolExecutor.php

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,7 @@ public function execute(string $toolClass, array $arguments = []): ToolResult
3030

3131
protected function executeInProcess(string $toolClass, array $arguments): ToolResult
3232
{
33-
$command = [
34-
PHP_BINARY,
35-
base_path('artisan'),
36-
'boost:execute-tool',
37-
$toolClass,
38-
base64_encode(json_encode($arguments)),
39-
];
33+
$command = $this->buildCommand($toolClass, $arguments);
4034

4135
$process = new Process($command);
4236
$process->setTimeout($this->getTimeout());
@@ -135,4 +129,22 @@ protected function reconstructToolResult(array $data): ToolResult
135129

136130
return ToolResult::text('');
137131
}
132+
133+
/**
134+
* Build the command array for executing a tool in a subprocess.
135+
*
136+
* @param string $toolClass
137+
* @param array<string, mixed> $arguments
138+
* @return array<string>
139+
*/
140+
protected function buildCommand(string $toolClass, array $arguments): array
141+
{
142+
return [
143+
PHP_BINARY,
144+
base_path('artisan'),
145+
'boost:execute-tool',
146+
$toolClass,
147+
base64_encode(json_encode($arguments)),
148+
];
149+
}
138150
}

tests/Feature/Mcp/ToolExecutorTest.php

Lines changed: 131 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
use Laravel\Boost\Mcp\ToolExecutor;
44
use Laravel\Boost\Mcp\Tools\ApplicationInfo;
5+
use Laravel\Boost\Mcp\Tools\GetConfig;
6+
use Laravel\Boost\Mcp\Tools\Tinker;
57
use Laravel\Mcp\Server\Tools\ToolResult;
68

79
test('can execute tool inline', function () {
@@ -14,10 +16,136 @@
1416
expect($result)->toBeInstanceOf(ToolResult::class);
1517
});
1618

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+
1748
test('rejects unregistered tools', function () {
1849
$executor = app(ToolExecutor::class);
19-
$result = $executor->execute('NonExistentToolClass', []);
50+
$result = $executor->execute('NonExistentToolClass');
2051

21-
expect($result)->toBeInstanceOf(ToolResult::class);
22-
expect($result->isError)->toBeTrue();
52+
expect($result)->toBeInstanceOf(ToolResult::class)
53+
->and($result->isError)->toBeTrue();
2354
});
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

Comments
 (0)