Skip to content

Commit cebd69e

Browse files
authored
Add Tinker MCP tool for executing PHP in app context (#807)
1 parent 02af8b4 commit cebd69e

4 files changed

Lines changed: 154 additions & 0 deletions

File tree

.ai/boost/core.blade.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,5 +29,9 @@
2929

3030
## Tinker
3131
- Execute PHP in app context for debugging and testing code. Do not create models without user approval, prefer tests with factories instead. Prefer existing Artisan commands over custom tinker code.
32+
@if($assist->hasMcpEnabled() && config('boost.tinker_tool_enabled', false))
33+
- Use the `tinker` MCP tool to execute PHP code instead of the CLI. It avoids shell escaping issues and runs the snippet in the Laravel application context.
34+
@else
3235
- Always use single quotes to prevent shell expansion: `{{ $assist->artisanCommand("tinker --execute 'Your::code();'") }}`
3336
- Double quotes for PHP strings inside: `{{ $assist->artisanCommand("tinker --execute 'User::where(\"active\", true)->count();'") }}`
37+
@endif

src/Mcp/Boost.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
use Laravel\Boost\Mcp\Tools\LastError;
1919
use Laravel\Boost\Mcp\Tools\ReadLogEntries;
2020
use Laravel\Boost\Mcp\Tools\SearchDocs;
21+
use Laravel\Boost\Mcp\Tools\Tinker;
2122
use Laravel\Mcp\Server;
2223
use Laravel\Mcp\Server\Prompt;
2324
use Laravel\Mcp\Server\Resource;
@@ -91,6 +92,7 @@ protected function discoverTools(): array
9192
LastError::class,
9293
ReadLogEntries::class,
9394
SearchDocs::class,
95+
Tinker::class,
9496
], 'tools');
9597
}
9698

src/Mcp/Tools/Tinker.php

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Laravel\Boost\Mcp\Tools;
6+
7+
use Illuminate\Contracts\JsonSchema\JsonSchema;
8+
use Illuminate\JsonSchema\Types\Type;
9+
use Illuminate\Support\Facades\Artisan;
10+
use Laravel\Mcp\Request;
11+
use Laravel\Mcp\Response;
12+
use Laravel\Mcp\Server\Tool;
13+
use Symfony\Component\Console\Command\Command as CommandAlias;
14+
use Symfony\Component\Console\Output\BufferedOutput;
15+
use Throwable;
16+
17+
class Tinker extends Tool
18+
{
19+
/**
20+
* The tool's description.
21+
*/
22+
protected string $description = 'Execute PHP code in the Laravel application context, like artisan tinker. Use this for debugging issues, checking if functions exist, and testing code snippets. You should not create models directly without explicit user approval. Prefer Unit/Feature tests using factories for functionality testing. Prefer existing artisan commands over custom tinker code.';
23+
24+
/**
25+
* Determine whether the tool should be registered with the MCP server.
26+
*/
27+
public function shouldRegister(): bool
28+
{
29+
return (bool) config('boost.tinker_tool_enabled', false);
30+
}
31+
32+
/**
33+
* Get the tool's input schema.
34+
*
35+
* @return array<string, Type>
36+
*/
37+
public function schema(JsonSchema $schema): array
38+
{
39+
return [
40+
'code' => $schema->string()
41+
->description('PHP code to execute (without opening <?php tags)')
42+
->required(),
43+
];
44+
}
45+
46+
/**
47+
* Handle the tool request.
48+
*/
49+
public function handle(Request $request): Response
50+
{
51+
$code = str_replace(['<?php', '?>'], '', (string) $request->get('code'));
52+
53+
$output = new BufferedOutput;
54+
55+
try {
56+
$exitCode = Artisan::call('tinker', [
57+
'--execute' => $code,
58+
'--no-ansi' => true,
59+
'--no-interaction' => true,
60+
], $output);
61+
} catch (Throwable $throwable) {
62+
return Response::text($throwable->getMessage());
63+
}
64+
65+
if ($exitCode !== CommandAlias::SUCCESS) {
66+
return Response::text('Failed to execute tinker: '.$output->fetch());
67+
}
68+
69+
return Response::text(trim($output->fetch()));
70+
}
71+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use Illuminate\Foundation\Application;
6+
use Illuminate\Support\Facades\Facade;
7+
use Laravel\Boost\Mcp\Tools\Tinker;
8+
use Laravel\Mcp\Request;
9+
use Laravel\Tinker\TinkerServiceProvider;
10+
use Orchestra\Testbench\Foundation\Application as Testbench;
11+
12+
use function Orchestra\Testbench\package_path;
13+
14+
beforeEach(function (): void {
15+
$result = Testbench::createVendorSymlink(base_path(), package_path('vendor'));
16+
$this->vendorSymlinkCreated = $result['TESTBENCH_VENDOR_SYMLINK'] ?? false;
17+
18+
Facade::clearResolvedInstances();
19+
Facade::setFacadeApplication($this->app);
20+
Application::setInstance($this->app);
21+
22+
$this->app->register(TinkerServiceProvider::class);
23+
});
24+
25+
afterEach(function (): void {
26+
if ($this->vendorSymlinkCreated ?? false) {
27+
Testbench::deleteVendorSymlink(base_path());
28+
}
29+
});
30+
31+
test('executes code and returns output', function (): void {
32+
$tool = new Tinker;
33+
$response = $tool->handle(new Request(['code' => 'echo "Hello World";']));
34+
35+
expect($response)->isToolResult()
36+
->toolHasNoError()
37+
->toolTextContains('Hello World');
38+
});
39+
40+
test('handles errors gracefully', function (): void {
41+
$tool = new Tinker;
42+
$response = $tool->handle(new Request(['code' => 'invalid syntax here']));
43+
44+
expect((string) $response->content())->toContain('Syntax error');
45+
});
46+
47+
test('strips php tags from code', function (): void {
48+
$tool = new Tinker;
49+
$response = $tool->handle(new Request(['code' => '<?php echo "stripped"; ?>']));
50+
51+
expect($response)->isToolResult()
52+
->toolHasNoError()
53+
->toolTextContains('stripped');
54+
});
55+
56+
test('executes code without semicolon', function (): void {
57+
$tool = new Tinker;
58+
$response = $tool->handle(new Request(['code' => 'echo 1']));
59+
60+
expect($response)->isToolResult()
61+
->toolHasNoError()
62+
->toolTextContains('1');
63+
});
64+
65+
test('is not registered by default', function (): void {
66+
$tool = new Tinker;
67+
68+
expect($tool->shouldRegister())->toBeFalse();
69+
});
70+
71+
test('is registered when tinker_tool_enabled config is true', function (): void {
72+
config()->set('boost.tinker_tool_enabled', true);
73+
74+
$tool = new Tinker;
75+
76+
expect($tool->shouldRegister())->toBeTrue();
77+
});

0 commit comments

Comments
 (0)