Skip to content

Commit 71bd728

Browse files
committed
feat: add 'tinker' tool
1 parent 07b4162 commit 71bd728

File tree

2 files changed

+312
-0
lines changed

2 files changed

+312
-0
lines changed

src/Mcp/Tools/Tinker.php

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
<?php
2+
3+
namespace Laravel\Boost\Mcp\Tools;
4+
5+
use Laravel\Mcp\Server\Tool;
6+
use Laravel\Mcp\Server\Tools\ToolInputSchema;
7+
use Laravel\Mcp\Server\Tools\ToolResult;
8+
use Throwable;
9+
10+
class Tinker extends Tool
11+
{
12+
public function shouldRegister(): bool
13+
{
14+
return app()->environment() === 'local';
15+
}
16+
17+
public function description(): string
18+
{
19+
return 'Execute PHP code in the Laravel application context, similar to artisan tinker. Most useful for debugging issues. Returns the output of the code, as well as whatever is "returned" using "return".';
20+
}
21+
22+
public function schema(ToolInputSchema $schema): ToolInputSchema
23+
{
24+
return $schema
25+
->string('code')
26+
->description('PHP code to execute (without opening <?php tags)')
27+
->required()
28+
->integer('timeout')
29+
->description('Maximum execution time in seconds (default: 30)');
30+
}
31+
32+
/**
33+
* @param array<string|int> $arguments
34+
*/
35+
public function handle(array $arguments): ToolResult
36+
{
37+
$code = str_replace(['<?php', '?>'], '', (string) $arguments['code']);
38+
$timeout = 30;
39+
if (! empty($arguments['timeout']) && is_int($arguments['timeout'])) {
40+
$timeout = $arguments['timeout'];
41+
}
42+
$timeout = min(180, $timeout);
43+
44+
// Set execution timeout
45+
set_time_limit($timeout);
46+
47+
// Set memory limit for safety
48+
ini_set('memory_limit', '128M');
49+
50+
// Use PCNTL alarm for additional timeout control if available (Unix only)
51+
if (function_exists('pcntl_async_signals') && function_exists('pcntl_signal')) {
52+
pcntl_async_signals(true);
53+
pcntl_signal(SIGALRM, function () {
54+
throw new \Exception('Code execution timed out');
55+
});
56+
pcntl_alarm($timeout);
57+
}
58+
59+
// Start output buffering to capture any output
60+
ob_start();
61+
62+
try {
63+
// Execute the code and capture the return value
64+
$result = eval($code);
65+
66+
// Cancel alarm if set
67+
if (function_exists('pcntl_alarm')) {
68+
pcntl_alarm(0);
69+
}
70+
71+
// Get any output that was printed
72+
$output = ob_get_contents();
73+
74+
// Clean the output buffer
75+
ob_end_clean();
76+
77+
// Prepare the response
78+
$response = [
79+
'result' => $result,
80+
'output' => $output,
81+
'type' => gettype($result),
82+
];
83+
84+
// If result is an object, include class name
85+
if (is_object($result)) {
86+
$response['class'] = get_class($result);
87+
}
88+
89+
return ToolResult::json($response);
90+
91+
} catch (Throwable $e) {
92+
// Cancel alarm if set
93+
if (function_exists('pcntl_alarm')) {
94+
pcntl_alarm(0);
95+
}
96+
97+
// Clean the output buffer on error
98+
ob_end_clean();
99+
100+
return ToolResult::json([
101+
'error' => $e->getMessage(),
102+
'type' => get_class($e),
103+
'file' => $e->getFile(),
104+
'line' => $e->getLine(),
105+
]);
106+
}
107+
}
108+
}
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
<?php
2+
3+
use Laravel\Boost\Mcp\Tools\Tinker;
4+
use Laravel\Mcp\Server\Tools\ToolResult;
5+
6+
function getToolResultData(ToolResult $result): array
7+
{
8+
$data = $result->toArray();
9+
10+
return json_decode($data['content'][0]['text'], true);
11+
}
12+
13+
test('executes simple php code', function () {
14+
$tool = new Tinker;
15+
$result = $tool->handle(['code' => 'return 2 + 2;']);
16+
17+
expect($result)->toBeInstanceOf(ToolResult::class);
18+
19+
$data = getToolResultData($result);
20+
expect($data['result'])->toBe(4)
21+
->and($data['type'])->toBe('integer');
22+
});
23+
24+
test('executes code with output', function () {
25+
$tool = new Tinker;
26+
$result = $tool->handle(['code' => 'echo "Hello World"; return "test";']);
27+
28+
expect($result)->toBeInstanceOf(ToolResult::class);
29+
30+
$data = getToolResultData($result);
31+
expect($data['result'])->toBe('test')
32+
->and($data['output'])->toBe('Hello World')
33+
->and($data['type'])->toBe('string');
34+
});
35+
36+
test('accesses laravel facades', function () {
37+
$tool = new Tinker;
38+
$result = $tool->handle(['code' => 'return config("app.name");']);
39+
40+
expect($result)->toBeInstanceOf(ToolResult::class);
41+
42+
$data = getToolResultData($result);
43+
expect($data['result'])->toBeString()
44+
->and($data['result'])->toBe(config('app.name'))
45+
->and($data['type'])->toBe('string');
46+
});
47+
48+
test('creates objects', function () {
49+
$tool = new Tinker;
50+
$result = $tool->handle(['code' => 'return new stdClass();']);
51+
52+
expect($result)->toBeInstanceOf(ToolResult::class);
53+
54+
$data = getToolResultData($result);
55+
expect($data['type'])->toBe('object')
56+
->and($data['class'])->toBe('stdClass');
57+
});
58+
59+
test('handles syntax errors', function () {
60+
$tool = new Tinker;
61+
$result = $tool->handle(['code' => 'invalid syntax here']);
62+
63+
expect($result)->toBeInstanceOf(ToolResult::class);
64+
65+
$resultArray = $result->toArray();
66+
expect($resultArray['isError'])->toBeFalse();
67+
68+
$data = getToolResultData($result);
69+
expect($data)->toHaveKey('error')
70+
->and($data)->toHaveKey('type')
71+
->and($data['type'])->toBe('ParseError');
72+
});
73+
74+
test('handles runtime errors', function () {
75+
$tool = new Tinker;
76+
$result = $tool->handle(['code' => 'throw new Exception("Test error");']);
77+
78+
expect($result)->toBeInstanceOf(ToolResult::class);
79+
80+
$resultArray = $result->toArray();
81+
expect($resultArray['isError'])->toBeFalse();
82+
83+
$data = getToolResultData($result);
84+
expect($data)->toHaveKey('error')
85+
->and($data['type'])->toBe('Exception')
86+
->and($data['error'])->toBe('Test error');
87+
});
88+
89+
test('captures multiple outputs', function () {
90+
$tool = new Tinker;
91+
$result = $tool->handle(['code' => 'echo "First"; echo "Second"; return "done";']);
92+
93+
expect($result)->toBeInstanceOf(ToolResult::class);
94+
95+
$data = getToolResultData($result);
96+
expect($data['result'])->toBe('done')
97+
->and($data['output'])->toBe('FirstSecond');
98+
});
99+
100+
test('executes code with different return types', function (string $code, mixed $expectedResult, string $expectedType) {
101+
$tool = new Tinker;
102+
$result = $tool->handle(['code' => $code]);
103+
104+
expect($result)->toBeInstanceOf(ToolResult::class);
105+
106+
$data = getToolResultData($result);
107+
expect($data['result'])->toBe($expectedResult)
108+
->and($data['type'])->toBe($expectedType);
109+
})->with([
110+
'integer' => ['return 42;', 42, 'integer'],
111+
'string' => ['return "hello";', 'hello', 'string'],
112+
'boolean true' => ['return true;', true, 'boolean'],
113+
'boolean false' => ['return false;', false, 'boolean'],
114+
'null' => ['return null;', null, 'NULL'],
115+
'array' => ['return [1, 2, 3];', [1, 2, 3], 'array'],
116+
'float' => ['return 3.14;', 3.14, 'double'],
117+
]);
118+
119+
test('handles empty code', function () {
120+
$tool = new Tinker;
121+
$result = $tool->handle(['code' => '']);
122+
123+
expect($result)->toBeInstanceOf(ToolResult::class);
124+
125+
$data = getToolResultData($result);
126+
expect($data['result'])->toBeFalse()
127+
->and($data['type'])->toBe('boolean');
128+
});
129+
130+
test('handles code with no return statement', function () {
131+
$tool = new Tinker;
132+
$result = $tool->handle(['code' => '$x = 5;']);
133+
134+
expect($result)->toBeInstanceOf(ToolResult::class);
135+
136+
$data = getToolResultData($result);
137+
expect($data['result'])->toBeNull()
138+
->and($data['type'])->toBe('NULL');
139+
});
140+
141+
test('should register only in local environment', function () {
142+
$tool = new Tinker;
143+
144+
// Test in local environment
145+
app()->detectEnvironment(function () {
146+
return 'local';
147+
});
148+
149+
expect($tool->shouldRegister())->toBeTrue();
150+
});
151+
152+
test('should not register in non-local environment', function () {
153+
$tool = new Tinker;
154+
155+
// Test in production environment
156+
app()->detectEnvironment(function () {
157+
return 'production';
158+
});
159+
160+
expect($tool->shouldRegister())->toBeFalse();
161+
});
162+
163+
test('uses custom timeout parameter', function () {
164+
$tool = new Tinker;
165+
$result = $tool->handle(['code' => 'return 2 + 2;', 'timeout' => 10]);
166+
167+
expect($result)->toBeInstanceOf(ToolResult::class);
168+
169+
$data = getToolResultData($result);
170+
expect($data['result'])->toBe(4)
171+
->and($data['type'])->toBe('integer');
172+
});
173+
174+
test('uses default timeout when not specified', function () {
175+
$tool = new Tinker;
176+
$result = $tool->handle(['code' => 'return 2 + 2;']);
177+
178+
expect($result)->toBeInstanceOf(ToolResult::class);
179+
180+
$data = getToolResultData($result);
181+
expect($data['result'])->toBe(4)
182+
->and($data['type'])->toBe('integer');
183+
});
184+
185+
test('times out when code takes too long', function () {
186+
$tool = new Tinker;
187+
188+
// Code that will take more than 1 second to execute
189+
$slowCode = '
190+
$start = microtime(true);
191+
while (microtime(true) - $start < 1.2) {
192+
usleep(50000); // Don\'t waste entire CPU
193+
}
194+
return "should not reach here";
195+
';
196+
197+
$result = $tool->handle(['code' => $slowCode, 'timeout' => 1]);
198+
199+
expect($result)->toBeInstanceOf(ToolResult::class);
200+
201+
$data = getToolResultData($result);
202+
expect($data)->toHaveKey('error')
203+
->and($data['error'])->toMatch('/(Maximum execution time|Code execution timed out)/');
204+
});

0 commit comments

Comments
 (0)