Skip to content

Commit 5c0bbde

Browse files
committed
use tail and grep commands to read log + add tests
1 parent cb4d5f3 commit 5c0bbde

File tree

5 files changed

+247
-59
lines changed

5 files changed

+247
-59
lines changed

src/Mcp/Tools/LogReader.php

Lines changed: 43 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
namespace Laravel\AiAssistant\Mcp\Tools;
44

55
use Generator;
6+
use Illuminate\Support\Facades\File;
7+
use Illuminate\Support\Facades\Process;
68
use Laravel\Mcp\Tools\Tool;
79
use Laravel\Mcp\Tools\ToolInputSchema;
810
use Laravel\Mcp\Tools\ToolResponse;
@@ -14,18 +16,24 @@ class LogReader extends Tool
1416
*/
1517
public function description(): string
1618
{
17-
return 'Use this tool to access the Laravel log for the development environment.';
19+
return 'Use this tool to tail and grep the local Laravel logs.';
1820
}
1921

2022
/**
2123
* The input schema of the tool.
2224
*/
2325
public function schema(ToolInputSchema $schema): ToolInputSchema
2426
{
25-
$schema->string('number_of_lines')
27+
$schema->integer('lines')
2628
->description('The number of lines to read from the end of the log.')
2729
->required();
2830

31+
$schema->string('log_path')
32+
->description('Optional path to the log file. Defaults to storage/logs/laravel.log if not provided.');
33+
34+
$schema->string('grep')
35+
->description('Optional grep pattern to filter log entries. Leave empty to get all lines.');
36+
2937
return $schema;
3038
}
3139

@@ -34,50 +42,51 @@ public function schema(ToolInputSchema $schema): ToolInputSchema
3442
*/
3543
public function handle(array $arguments): ToolResponse|Generator
3644
{
37-
$numberOfLines = (int) $arguments['number_of_lines'];
38-
$logPath = storage_path('logs/laravel.log');
45+
$numberOfLines = $arguments['lines'];
46+
$grepPattern = $arguments['grep'] ?? null;
47+
48+
$logPath = isset($arguments['log_path']) && $arguments['log_path'] !== ''
49+
? $arguments['log_path']
50+
: storage_path('logs/laravel.log');
51+
52+
if (! str_starts_with($logPath, '/')) {
53+
$logPath = base_path($logPath);
54+
}
3955

40-
if (! file_exists($logPath) || ! is_readable($logPath)) {
41-
return new ToolResponse('Log file not found or is not readable.');
56+
if (! $this->logFileExistsAndIsReadable($logPath)) {
57+
return new ToolResponse("Log file not found or is not readable: {$logPath}");
4258
}
4359

44-
$handle = fopen($logPath, 'rb');
60+
if ($grepPattern) {
61+
$command = ['sh', '-c', "grep ".escapeshellarg($grepPattern)." ".escapeshellarg($logPath)." | tail -n {$numberOfLines}"];
62+
} else {
63+
$command = ['tail', '-n', (string) $numberOfLines, $logPath];
64+
}
65+
66+
$result = Process::run($command);
4567

46-
if (! $handle) {
47-
return new ToolResponse('Unable to open log file.');
68+
if (! $result->successful()) {
69+
return new ToolResponse("Failed to read log file. Error: ".trim($result->errorOutput()));
4870
}
4971

50-
try {
51-
$output = $this->tail($handle, $numberOfLines);
52-
} finally {
53-
fclose($handle);
72+
$output = $result->output();
73+
74+
if (trim($output) === '') {
75+
if ($grepPattern) {
76+
return new ToolResponse("No log entries found matching pattern: {$grepPattern}");
77+
} else {
78+
return new ToolResponse('Log file is empty or no entries found.');
79+
}
5480
}
5581

56-
return new ToolResponse($output);
82+
return new ToolResponse(trim($output));
5783
}
5884

5985
/**
60-
* Efficiently read the last N lines from a file handle.
61-
*
62-
* @param resource $handle
86+
* Check if the log file exists and is readable.
6387
*/
64-
protected function tail($handle, int $lines): string
88+
private function logFileExistsAndIsReadable(string $logPath): bool
6589
{
66-
fseek($handle, 0, SEEK_END);
67-
$position = ftell($handle);
68-
$output = '';
69-
$lineCount = 0;
70-
$bufferSize = 4096;
71-
72-
while ($position > 0 && $lineCount <= $lines) {
73-
$seek = min($position, $bufferSize);
74-
fseek($handle, $position - $seek, SEEK_SET);
75-
$chunk = fread($handle, $seek);
76-
$output = $chunk.$output;
77-
$position -= $seek;
78-
$lineCount = substr_count($output, "\n");
79-
}
80-
81-
return implode("\n", array_slice(explode("\n", $output), -$lines));
90+
return File::exists($logPath) && File::isReadable($logPath);
8291
}
8392
}

src/Mcp/Tools/ToolResourceResponse.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ public function __construct(private Resource $resource) {}
1818
public function toArray(): array
1919
{
2020
return [
21-
'contents' => [
21+
'content' => [
2222
[
2323
'type' => 'resource',
2424
'resource' => [

tests/Feature/FeatureTest.php

Lines changed: 0 additions & 24 deletions
This file was deleted.
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
<?php
2+
3+
use Illuminate\Support\Facades\File;
4+
use Illuminate\Support\Facades\Process;
5+
use Laravel\AiAssistant\Mcp\Tools\LogReader;
6+
use Laravel\Mcp\Tools\ToolResponse;
7+
8+
it('calls process with the correct log path when one is provided', function () {
9+
Process::fake([
10+
'*' => Process::result(output: 'test output'),
11+
]);
12+
13+
File::shouldReceive('exists')->andReturn(true);
14+
File::shouldReceive('isReadable')->andReturn(true);
15+
16+
$tool = new LogReader();
17+
18+
$tool->handle([
19+
'lines' => 10,
20+
'log_path' => 'my/custom/log.log',
21+
]);
22+
23+
Process::assertRan(function ($process) {
24+
return $process->command === ['tail', '-n', '10', base_path('my/custom/log.log')];
25+
});
26+
});
27+
28+
it('calls process with the correct log path when an absolute path is provided', function () {
29+
Process::fake([
30+
'*' => Process::result(output: 'test output'),
31+
]);
32+
33+
File::shouldReceive('exists')->andReturn(true);
34+
File::shouldReceive('isReadable')->andReturn(true);
35+
36+
$tool = new LogReader();
37+
$absolutePath = '/var/logs/my-app.log';
38+
$tool->handle([
39+
'lines' => 10,
40+
'log_path' => $absolutePath,
41+
]);
42+
43+
Process::assertRan(function ($process) use ($absolutePath) {
44+
return $process->command === ['tail', '-n', '10', $absolutePath];
45+
});
46+
});
47+
48+
it('calls process with the default log path when none is provided', function () {
49+
Process::fake([
50+
'*' => Process::result(output: 'test output'),
51+
]);
52+
53+
File::shouldReceive('exists')->andReturn(true);
54+
File::shouldReceive('isReadable')->andReturn(true);
55+
56+
$tool = new LogReader();
57+
$tool->handle([
58+
'lines' => 10,
59+
]);
60+
61+
Process::assertRan(function ($process) {
62+
return $process->command === ['tail', '-n', '10', storage_path('logs/laravel.log')];
63+
});
64+
});
65+
66+
it('calls process with the default log path when an empty string is provided', function () {
67+
Process::fake([
68+
'*' => Process::result(output: 'test output'),
69+
]);
70+
71+
File::shouldReceive('exists')->andReturn(true);
72+
File::shouldReceive('isReadable')->andReturn(true);
73+
74+
$tool = new LogReader();
75+
$tool->handle([
76+
'lines' => 10,
77+
'log_path' => '',
78+
]);
79+
80+
Process::assertRan(function ($process) {
81+
return $process->command === ['tail', '-n', '10', storage_path('logs/laravel.log')];
82+
});
83+
});
84+
85+
it('calls process with grep pattern when provided', function () {
86+
Process::fake([
87+
'*' => Process::result(output: 'test output'),
88+
]);
89+
90+
File::shouldReceive('exists')->andReturn(true);
91+
File::shouldReceive('isReadable')->andReturn(true);
92+
93+
$tool = new LogReader();
94+
$tool->handle([
95+
'lines' => 10,
96+
'grep' => 'error',
97+
]);
98+
99+
Process::assertRan(function ($process) {
100+
$logPath = escapeshellarg(storage_path('logs/laravel.log'));
101+
$grepPattern = escapeshellarg('error');
102+
$expectedCommand = "grep {$grepPattern} {$logPath} | tail -n 10";
103+
104+
return $process->command === ['sh', '-c', $expectedCommand];
105+
});
106+
});
107+
108+
it('returns an error if the log file does not exist', function () {
109+
File::shouldReceive('exists')->andReturn(false);
110+
111+
$tool = new LogReader();
112+
$response = $tool->handle([
113+
'lines' => 10,
114+
]);
115+
116+
$logPath = storage_path('logs/laravel.log');
117+
expect($response)->toEqual(new ToolResponse("Log file not found or is not readable: {$logPath}"));
118+
});
119+
120+
it('returns an error if the log file is not readable', function () {
121+
File::shouldReceive('exists')->andReturn(true);
122+
File::shouldReceive('isReadable')->andReturn(false);
123+
124+
$tool = new LogReader();
125+
$response = $tool->handle([
126+
'lines' => 10,
127+
]);
128+
129+
$logPath = storage_path('logs/laravel.log');
130+
expect($response)->toEqual(new ToolResponse("Log file not found or is not readable: {$logPath}"));
131+
});
132+
133+
it('returns an error if the process fails', function () {
134+
Process::fake([
135+
'*' => Process::result(
136+
output: '',
137+
errorOutput: 'Something went wrong',
138+
exitCode: 1
139+
),
140+
]);
141+
142+
File::shouldReceive('exists')->andReturn(true);
143+
File::shouldReceive('isReadable')->andReturn(true);
144+
145+
$tool = new LogReader();
146+
$response = $tool->handle([
147+
'lines' => 10,
148+
]);
149+
150+
expect($response)->toEqual(new ToolResponse("Failed to read log file. Error: Something went wrong"));
151+
});
152+
153+
it('returns a message if no log entries match the grep pattern', function () {
154+
Process::fake([
155+
'*' => Process::result(output: ' '),
156+
]);
157+
158+
File::shouldReceive('exists')->andReturn(true);
159+
File::shouldReceive('isReadable')->andReturn(true);
160+
161+
$tool = new LogReader();
162+
$response = $tool->handle([
163+
'lines' => 10,
164+
'grep' => 'non_existent_pattern',
165+
]);
166+
167+
expect($response)->toEqual(new ToolResponse("No log entries found matching pattern: non_existent_pattern"));
168+
});
169+
170+
it('returns a message if the log file is empty', function () {
171+
Process::fake([
172+
'*' => Process::result(output: ''),
173+
]);
174+
175+
File::shouldReceive('exists')->andReturn(true);
176+
File::shouldReceive('isReadable')->andReturn(true);
177+
178+
$tool = new LogReader();
179+
$response = $tool->handle([
180+
'lines' => 10,
181+
]);
182+
183+
expect($response)->toEqual(new ToolResponse('Log file is empty or no entries found.'));
184+
});
185+
186+
it('returns the log content on success', function () {
187+
Process::fake([
188+
'*' => Process::result(output: " log line 1 \n log line 2 "),
189+
]);
190+
191+
File::shouldReceive('exists')->andReturn(true);
192+
File::shouldReceive('isReadable')->andReturn(true);
193+
194+
$tool = new LogReader();
195+
$response = $tool->handle([
196+
'lines' => 10,
197+
]);
198+
199+
expect($response)->toEqual(new ToolResponse("log line 1 \n log line 2"));
200+
});

tests/TestCase.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,16 @@
55
use Illuminate\Support\Facades\Artisan;
66
use Laravel\AiAssistant\AiAssistant;
77
use Laravel\AiAssistant\AiAssistantServiceProvider;
8+
use Laravel\Mcp\Registrar;
89
use Orchestra\Testbench\TestCase as OrchestraTestCase;
910

1011
abstract class TestCase extends OrchestraTestCase
1112
{
1213
protected function defineEnvironment($app)
1314
{
1415
Artisan::call('vendor:publish', ['--tag' => 'ai-assistant-assets']);
16+
17+
$app->singleton('mcp', Registrar::class);
1518
}
1619

1720
protected function setUp(): void

0 commit comments

Comments
 (0)